@tangle-network/ui 11.12.0 → 11.13.0
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 +17 -0
- package/dist/_virtual/_rolldown/runtime.js +13 -0
- package/dist/auth/auth.d.ts +53 -0
- package/dist/{auth-DOETfnd9.js → auth/auth.js} +7 -37
- package/dist/auth/login-layout.d.ts +20 -0
- package/dist/auth/login-layout.js +36 -0
- package/dist/auth.d.ts +2 -1
- package/dist/auth.js +2 -1
- package/dist/chat/agent-timeline.d.ts +83 -0
- package/dist/chat/agent-timeline.js +236 -0
- package/dist/chat/chat-container.d.ts +51 -0
- package/dist/chat/chat-container.js +309 -0
- package/dist/chat/chat-message.d.ts +22 -0
- package/dist/chat/chat-message.js +38 -0
- package/dist/chat/message-list.d.ts +32 -0
- package/dist/chat/message-list.js +37 -0
- package/dist/chat/thinking-indicator.d.ts +6 -0
- package/dist/chat/thinking-indicator.js +38 -0
- package/dist/chat/user-message.d.ts +21 -0
- package/dist/chat/user-message.js +42 -0
- package/dist/chat.d.ts +6 -1
- package/dist/chat.js +6 -1
- package/dist/editor/document-editor-pane.d.ts +37 -0
- package/dist/editor/document-editor-pane.js +199 -0
- package/dist/editor/editor-lazy.js +37 -0
- package/dist/editor/editor-loading.js +21 -0
- package/dist/editor/editor-peers.d.ts +6 -0
- package/dist/editor/editor-peers.js +155 -0
- package/dist/editor/editor-provider.d.ts +97 -0
- package/dist/editor/editor-provider.js +253 -0
- package/dist/editor/editor-toolbar.d.ts +11 -0
- package/dist/editor/editor-toolbar.js +106 -0
- package/dist/editor/markdown-conversion.js +23 -0
- package/dist/editor/markdown-document-editor.js +103 -0
- package/dist/editor/tiptap-editor.d.ts +45 -0
- package/dist/editor/tiptap-editor.js +271 -0
- package/dist/editor/use-editor.d.ts +67 -0
- package/dist/editor/use-editor.js +158 -0
- package/dist/editor.d.ts +6 -119
- package/dist/editor.js +5 -1
- package/dist/files/file-artifact-pane.d.ts +47 -0
- package/dist/files/file-artifact-pane.js +114 -0
- package/dist/files/file-format.d.ts +51 -0
- package/dist/{file-format-BgXbr-Ib.js → files/file-format.js} +1 -1
- package/dist/files/file-preview.d.ts +27 -0
- package/dist/files/file-preview.js +359 -0
- package/dist/files/file-tabs.d.ts +19 -0
- package/dist/files/file-tabs.js +48 -0
- package/dist/files/file-tree.d.ts +31 -0
- package/dist/files/file-tree.js +135 -0
- package/dist/files/rich-file-tree.d.ts +62 -0
- package/dist/files/rich-file-tree.js +122 -0
- package/dist/files.d.ts +6 -1
- package/dist/files.js +6 -2
- package/dist/hooks/use-auth.d.ts +40 -0
- package/dist/hooks/use-auth.js +91 -0
- package/dist/hooks/use-auto-scroll.d.ts +11 -0
- package/dist/hooks/use-auto-scroll.js +48 -0
- package/dist/hooks/use-dropdown-menu.d.ts +12 -0
- package/dist/hooks/use-dropdown-menu.js +31 -0
- package/dist/hooks/use-live-time.d.ts +7 -0
- package/dist/hooks/use-live-time.js +22 -0
- package/dist/hooks/use-realtime-session.d.ts +32 -0
- package/dist/hooks/use-realtime-session.js +235 -0
- package/dist/hooks/use-run-collapse-state.d.ts +13 -0
- package/dist/hooks/use-run-collapse-state.js +24 -0
- package/dist/hooks/use-run-groups.d.ts +11 -0
- package/dist/hooks/use-run-groups.js +85 -0
- package/dist/hooks/use-sdk-session.d.ts +59 -0
- package/dist/hooks/use-sdk-session.js +322 -0
- package/dist/hooks/use-sse-stream.d.ts +124 -0
- package/dist/hooks/use-sse-stream.js +229 -0
- package/dist/hooks/use-tool-call-stream.d.ts +23 -0
- package/dist/hooks/use-tool-call-stream.js +75 -0
- package/dist/hooks.d.ts +11 -49
- package/dist/hooks.js +10 -110
- package/dist/index.d.ts +99 -25
- package/dist/index.js +86 -36
- package/dist/lib/focus.d.ts +55 -0
- package/dist/{focus-BcE_SHqz.js → lib/focus.js} +1 -1
- package/dist/lib/utils.d.ts +4 -0
- package/dist/{utils-C_uf36nf.js → lib/utils.js} +1 -1
- package/dist/{code-block-B9miUPht.d.ts → markdown/code-block.d.ts} +5 -6
- package/dist/{code-block-B36KVUSN.js → markdown/code-block.js} +22 -47
- package/dist/{markdown-C0atSabr.d.ts → markdown/markdown.d.ts} +4 -5
- package/dist/{markdown-Db08KAYF.js → markdown/markdown.js} +3 -3
- package/dist/markdown.d.ts +2 -2
- package/dist/markdown.js +2 -2
- package/dist/{openui-artifact-renderer-fkBQaoLO.d.ts → openui/openui-artifact-renderer.d.ts} +20 -21
- package/dist/{openui-artifact-renderer-DG_i2lOR.js → openui/openui-artifact-renderer.js} +9 -7
- package/dist/{schema-BpW9rSSG.js → openui/schema-compiled.js} +2 -354
- package/dist/{schema-DAstk-J4.d.ts → openui/schema-data.d.ts} +3 -24
- package/dist/openui/schema-data.js +206 -0
- package/dist/openui-schema.d.ts +22 -3
- package/dist/openui-schema.js +138 -1
- package/dist/openui.d.ts +3 -2
- package/dist/openui.js +3 -2
- package/dist/{artifact-pane-BL_DFWNl.d.ts → primitives/artifact-pane.d.ts} +3 -4
- package/dist/{artifact-pane-DMXT9a8U.js → primitives/artifact-pane.js} +2 -2
- package/dist/primitives/avatar.d.ts +8 -0
- package/dist/primitives/avatar.js +25 -0
- package/dist/primitives/badge.d.ts +22 -0
- package/dist/primitives/badge.js +47 -0
- package/dist/{button-yoIv77e2.d.ts → primitives/button.d.ts} +2 -2
- package/dist/primitives/button.js +113 -0
- package/dist/primitives/card.d.ts +18 -0
- package/dist/primitives/card.js +52 -0
- package/dist/primitives/code-block.d.ts +6 -0
- package/dist/primitives/code-block.js +13 -0
- package/dist/primitives/dialog.d.ts +23 -0
- package/dist/primitives/dialog.js +59 -0
- package/dist/primitives/drop-zone.d.ts +23 -0
- package/dist/primitives/drop-zone.js +104 -0
- package/dist/primitives/dropdown-menu.d.ts +29 -0
- package/dist/{dropdown-menu-BpcLa5vF.js → primitives/dropdown-menu.js} +2 -23
- package/dist/primitives/empty-state.d.ts +11 -0
- package/dist/primitives/empty-state.js +32 -0
- package/dist/primitives/heading.d.ts +23 -0
- package/dist/primitives/heading.js +38 -0
- package/dist/primitives/input.d.ts +23 -0
- package/dist/primitives/input.js +110 -0
- package/dist/primitives/label.d.ts +6 -0
- package/dist/primitives/label.js +16 -0
- package/dist/primitives/metric-strip.d.ts +48 -0
- package/dist/primitives/metric-strip.js +52 -0
- package/dist/primitives/page-header.d.ts +25 -0
- package/dist/primitives/page-header.js +47 -0
- package/dist/primitives/page-shell.d.ts +14 -0
- package/dist/primitives/page-shell.js +17 -0
- package/dist/primitives/progress.d.ts +9 -0
- package/dist/primitives/progress.js +29 -0
- package/dist/primitives/segmented-control.d.ts +40 -0
- package/dist/primitives/segmented-control.js +57 -0
- package/dist/primitives/select.d.ts +15 -0
- package/dist/primitives/select.js +74 -0
- package/dist/primitives/sidebar-drop-zone.d.ts +21 -0
- package/dist/primitives/sidebar-drop-zone.js +88 -0
- package/dist/primitives/skeleton.d.ts +10 -0
- package/dist/primitives/skeleton.js +50 -0
- package/dist/primitives/stat-card.d.ts +16 -0
- package/dist/primitives/stat-card.js +60 -0
- package/dist/primitives/status-pill.d.ts +44 -0
- package/dist/primitives/status-pill.js +119 -0
- package/dist/primitives/switch.d.ts +6 -0
- package/dist/primitives/switch.js +16 -0
- package/dist/primitives/table.d.ts +22 -0
- package/dist/primitives/table.js +63 -0
- package/dist/primitives/tabs.d.ts +13 -0
- package/dist/{tabs-BVHR6NkO.js → primitives/tabs.js} +3 -3
- package/dist/primitives/terminal-display.d.ts +25 -0
- package/dist/primitives/terminal-display.js +109 -0
- package/dist/primitives/text-shimmer.d.ts +16 -0
- package/dist/{text-shimmer-BUUo6I-g.js → primitives/text-shimmer.js} +2 -2
- package/dist/primitives/theme-toggle.d.ts +11 -0
- package/dist/primitives/theme-toggle.js +154 -0
- package/dist/primitives/toast.d.ts +29 -0
- package/dist/primitives/toast.js +142 -0
- package/dist/primitives/toolbar.d.ts +55 -0
- package/dist/primitives/toolbar.js +37 -0
- package/dist/primitives/upload-progress.d.ts +23 -0
- package/dist/primitives/upload-progress.js +72 -0
- package/dist/primitives.d.ts +35 -5
- package/dist/primitives.js +35 -9
- package/dist/{index-COgqsFAQ.d.ts → redaction/redacted-document.d.ts} +6 -7
- package/dist/{redaction-CGGB2ENG.js → redaction/redacted-document.js} +2 -2
- package/dist/redaction.d.ts +1 -1
- package/dist/redaction.js +1 -1
- package/dist/run/expandable-content.js +41 -0
- package/dist/run/expanded-tool-detail.d.ts +12 -0
- package/dist/run/expanded-tool-detail.js +163 -0
- package/dist/run/inline-thinking-item.d.ts +11 -0
- package/dist/run/inline-thinking-item.js +60 -0
- package/dist/run/inline-tool-item.d.ts +25 -0
- package/dist/run/inline-tool-item.js +63 -0
- package/dist/run/run-group.d.ts +26 -0
- package/dist/{run-group-BWGeesgs.js → run/run-group.js} +9 -60
- package/dist/run/run-item-primitives.d.ts +5 -0
- package/dist/{run-ZsIKPA6J.js → run/run-item-primitives.js} +2 -2
- package/dist/run/run-row-shell.d.ts +53 -0
- package/dist/run/run-row-shell.js +114 -0
- package/dist/{tool-call-feed-BeB1_4pX.d.ts → run/tool-call-feed.d.ts} +7 -12
- package/dist/{tool-call-feed-BpmMLpPX.js → run/tool-call-feed.js} +4 -4
- package/dist/run/tool-call-step.d.ts +5 -0
- package/dist/run/tool-call-step.js +82 -0
- package/dist/run.d.ts +8 -2
- package/dist/run.js +7 -4
- package/dist/sdk-hooks.d.ts +8 -1
- package/dist/sdk-hooks.js +8 -2
- package/dist/stores/active-sessions-store.d.ts +83 -0
- package/dist/{active-sessions-store-Gt1R0rFG.js → stores/active-sessions-store.js} +1 -1
- package/dist/stores/chat-store.d.ts +14 -0
- package/dist/stores/chat-store.js +33 -0
- package/dist/stores.d.ts +3 -16
- package/dist/stores.js +2 -33
- package/dist/tool-previews/command-preview.d.ts +11 -0
- package/dist/tool-previews/command-preview.js +91 -0
- package/dist/tool-previews/diff-preview.d.ts +7 -0
- package/dist/tool-previews/diff-preview.js +76 -0
- package/dist/tool-previews/glob-results-preview.d.ts +7 -0
- package/dist/tool-previews/glob-results-preview.js +53 -0
- package/dist/tool-previews/grep-results-preview.d.ts +7 -0
- package/dist/tool-previews/grep-results-preview.js +99 -0
- package/dist/tool-previews/preview-primitives.js +58 -0
- package/dist/tool-previews/question-preview.d.ts +7 -0
- package/dist/tool-previews/question-preview.js +63 -0
- package/dist/tool-previews/web-search-preview.d.ts +7 -0
- package/dist/tool-previews/web-search-preview.js +78 -0
- package/dist/tool-previews/write-file-preview.d.ts +11 -0
- package/dist/tool-previews/write-file-preview.js +52 -0
- package/dist/tool-previews.d.ts +7 -1
- package/dist/tool-previews.js +7 -1
- package/dist/{branding-26d5ARIr.d.ts → types/branding.d.ts} +2 -3
- package/dist/{message-vhUI0c5G.d.ts → types/message.d.ts} +2 -3
- package/dist/{parts-5mhy9yJ3.d.ts → types/parts.d.ts} +8 -9
- package/dist/{run-OyWmIDqu.d.ts → types/run.d.ts} +9 -10
- package/dist/{tool-display-DgmL5rIU.d.ts → types/tool-display.d.ts} +5 -6
- package/dist/types.d.ts +5 -5
- package/dist/utils/copy-text.d.ts +7 -0
- package/dist/{utils-Bl_CZ0Qp.js → utils/copy-text.js} +1 -11
- package/dist/utils/format.d.ts +22 -0
- package/dist/{format-DfcLWqMa.js → utils/format.js} +1 -1
- package/dist/utils/time-ago.d.ts +3 -0
- package/dist/utils/time-ago.js +11 -0
- package/dist/utils/tool-display.d.ts +16 -0
- package/dist/{tool-display-BAuAVa43.js → utils/tool-display.js} +1 -1
- package/dist/utils.d.ts +6 -1
- package/dist/utils.js +6 -5
- package/package.json +2 -2
- package/src/files/rich-file-tree.stories.tsx +1 -1
- package/src/files/rich-file-tree.tsx +12 -9
- package/src/markdown/code-block.test.tsx +46 -2
- package/src/markdown/code-block.tsx +23 -60
- package/src/primitives/button.stories.tsx +37 -1
- package/src/primitives/button.test.tsx +230 -0
- package/src/primitives/button.tsx +62 -14
- package/src/primitives/card-anatomy.stories.tsx +51 -0
- package/src/primitives/card.test.tsx +71 -0
- package/src/primitives/card.tsx +25 -13
- package/src/primitives/heading.stories.tsx +35 -0
- package/src/primitives/heading.test.tsx +56 -0
- package/src/primitives/heading.tsx +76 -0
- package/src/primitives/index.ts +8 -0
- package/src/primitives/input.stories.tsx +28 -0
- package/src/primitives/input.test.tsx +161 -0
- package/src/primitives/input.tsx +34 -28
- package/src/primitives/page-header.stories.tsx +51 -0
- package/src/primitives/page-header.test.tsx +48 -0
- package/src/primitives/page-header.tsx +35 -35
- package/src/primitives/page-shell.stories.tsx +89 -0
- package/src/primitives/page-shell.test.tsx +24 -0
- package/src/primitives/page-shell.tsx +26 -0
- package/src/primitives/table-wrapper.stories.tsx +54 -0
- package/src/primitives/table.test.tsx +72 -0
- package/src/primitives/table.tsx +33 -12
- package/src/primitives/theme-toggle.test.tsx +167 -0
- package/src/primitives/theme-toggle.tsx +111 -39
- package/dist/active-sessions-store-nl70ZnMC.d.ts +0 -84
- package/dist/button-C87nEe2R.js +0 -78
- package/dist/chat-DDvF2oGQ.js +0 -672
- package/dist/diff-preview-BcK3nRXb.js +0 -533
- package/dist/document-editor-pane-BilJfiCo.d.ts +0 -136
- package/dist/document-editor-pane-POsq5Q0o.js +0 -1279
- package/dist/files-hywocVZz.js +0 -753
- package/dist/index-2UntyjBb.d.ts +0 -52
- package/dist/index-BgUHilZm.d.ts +0 -108
- package/dist/index-CJ8wRfiC.d.ts +0 -236
- package/dist/index-CPDET_s1.d.ts +0 -549
- package/dist/index-Cg6gYAqh.d.ts +0 -73
- package/dist/index-Cs7j8V7l.d.ts +0 -203
- package/dist/index-DjKxO357.d.ts +0 -128
- package/dist/primitives-Bx59fT-U.js +0 -1239
- package/dist/table-Bss73DhB.js +0 -146
- package/dist/tool-call-step-DNebqhmX.js +0 -434
- package/dist/use-auto-scroll-C5Pjeqon.js +0 -153
- package/dist/use-sse-stream-DjqRuDft.js +0 -884
- package/dist/use-sse-stream-Dm6R5YWz.d.ts +0 -282
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createRef } from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, type CardTitleProps } from "./card";
|
|
6
|
+
|
|
7
|
+
describe("Card anatomy", () => {
|
|
8
|
+
it("keeps the existing default title tag and heading ref", () => {
|
|
9
|
+
const ref = createRef<HTMLHeadingElement>();
|
|
10
|
+
render(<CardTitle ref={ref} id="card-title">Details</CardTitle>);
|
|
11
|
+
expect(ref.current).toBe(screen.getByRole("heading", { level: 3 }));
|
|
12
|
+
expect(ref.current).toHaveAttribute("id", "card-title");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it.each([1, 2, 3, 4, 5, 6] as const)("allows an h%i without changing its visual treatment", (level) => {
|
|
16
|
+
const { rerender } = render(<CardTitle>Details</CardTitle>);
|
|
17
|
+
const classes = screen.getByRole("heading").className;
|
|
18
|
+
rerender(<CardTitle as={`h${level}` as CardTitleProps["as"]}>Details</CardTitle>);
|
|
19
|
+
expect(screen.getByRole("heading", { level }).className).toBe(classes);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("preserves native attributes and caller classes", () => {
|
|
23
|
+
render(<CardTitle as="h2" role="presentation" className="text-xl" data-kind="summary">Summary</CardTitle>);
|
|
24
|
+
const title = screen.getByText("Summary");
|
|
25
|
+
expect(title).toHaveAttribute("role", "presentation");
|
|
26
|
+
expect(title).toHaveAttribute("data-kind", "summary");
|
|
27
|
+
expect(title).toHaveClass("text-xl");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("uses the same gutter on every part and only removes subsequent top padding", () => {
|
|
31
|
+
render(<Card>
|
|
32
|
+
<CardHeader data-testid="header"><CardTitle>Details</CardTitle><CardDescription>Prose</CardDescription></CardHeader>
|
|
33
|
+
<CardContent data-testid="content">Body</CardContent>
|
|
34
|
+
<CardFooter data-testid="footer">Footer</CardFooter>
|
|
35
|
+
</Card>);
|
|
36
|
+
for (const part of ["header", "content", "footer"]) {
|
|
37
|
+
expect(screen.getByTestId(part)).toHaveClass("p-4");
|
|
38
|
+
expect(screen.getByTestId(part)).not.toHaveClass("p-6");
|
|
39
|
+
}
|
|
40
|
+
expect(screen.getByTestId("content")).toHaveClass("[&:not(:first-child)]:pt-0");
|
|
41
|
+
expect(screen.getByTestId("footer")).toHaveClass("[&:not(:first-child)]:pt-0");
|
|
42
|
+
// These are class/DOM contracts, not a browser-computed padding assertion.
|
|
43
|
+
expect(screen.getByText("Prose").tagName).toBe("P");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does not strip the top padding unconditionally from content-only cards", () => {
|
|
47
|
+
const ref = createRef<HTMLDivElement>();
|
|
48
|
+
render(<Card><CardContent ref={ref}>Standalone</CardContent></Card>);
|
|
49
|
+
expect(ref.current).toHaveClass("p-4");
|
|
50
|
+
expect(ref.current).not.toHaveClass("pt-0");
|
|
51
|
+
expect(ref.current?.matches(":first-child")).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("keeps hover decorative and leaves keyboard activation to a real button", async () => {
|
|
55
|
+
const user = userEvent.setup();
|
|
56
|
+
const activate = vi.fn();
|
|
57
|
+
const ref = createRef<HTMLDivElement>();
|
|
58
|
+
render(<Card ref={ref} hover>
|
|
59
|
+
<CardContent><button type="button" onClick={activate}>Open details</button></CardContent>
|
|
60
|
+
</Card>);
|
|
61
|
+
expect(ref.current?.tagName).toBe("DIV");
|
|
62
|
+
expect(ref.current).not.toHaveAttribute("tabindex");
|
|
63
|
+
expect(ref.current).not.toHaveAttribute("role");
|
|
64
|
+
expect(ref.current).not.toHaveClass("cursor-pointer");
|
|
65
|
+
await user.tab();
|
|
66
|
+
expect(screen.getByRole("button", { name: "Open details" })).toHaveFocus();
|
|
67
|
+
await user.keyboard("{Enter}");
|
|
68
|
+
await user.keyboard(" ");
|
|
69
|
+
expect(activate).toHaveBeenCalledTimes(2);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/primitives/card.tsx
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { cn } from "../lib/utils";
|
|
3
|
+
import { Heading } from "./heading";
|
|
3
4
|
|
|
4
5
|
const Card = React.forwardRef<
|
|
5
6
|
HTMLDivElement,
|
|
6
7
|
React.HTMLAttributes<HTMLDivElement> & {
|
|
7
8
|
variant?: "default" | "glass" | "sandbox" | "elevated";
|
|
9
|
+
/** Decorative border feedback only. Put actions in a native button or link. */
|
|
8
10
|
hover?: boolean;
|
|
9
11
|
}
|
|
10
12
|
>(({ className, variant = "default", hover = false, ...props }, ref) => {
|
|
@@ -22,7 +24,7 @@ const Card = React.forwardRef<
|
|
|
22
24
|
"rounded-[var(--radius-lg)] border text-card-foreground transition-[border-color,box-shadow]",
|
|
23
25
|
"duration-[var(--transition-default)]",
|
|
24
26
|
variants[variant],
|
|
25
|
-
hover && "
|
|
27
|
+
hover && "hover:border-[var(--border-strong)]",
|
|
26
28
|
className,
|
|
27
29
|
)}
|
|
28
30
|
{...props}
|
|
@@ -43,16 +45,22 @@ const CardHeader = React.forwardRef<
|
|
|
43
45
|
));
|
|
44
46
|
CardHeader.displayName = "CardHeader";
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
48
|
+
export interface CardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
|
|
49
|
+
/** Heading level appropriate to the surrounding document; h3 remains the default. */
|
|
50
|
+
as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const CardTitle = React.forwardRef<HTMLHeadingElement, CardTitleProps>(
|
|
54
|
+
({ className, as = "h3", ...props }, ref) => (
|
|
55
|
+
<Heading
|
|
56
|
+
ref={ref}
|
|
57
|
+
variant="subsection"
|
|
58
|
+
as={as}
|
|
59
|
+
className={className}
|
|
60
|
+
{...props}
|
|
61
|
+
/>
|
|
62
|
+
),
|
|
63
|
+
);
|
|
56
64
|
CardTitle.displayName = "CardTitle";
|
|
57
65
|
|
|
58
66
|
const CardDescription = React.forwardRef<
|
|
@@ -71,7 +79,11 @@ const CardContent = React.forwardRef<
|
|
|
71
79
|
HTMLDivElement,
|
|
72
80
|
React.HTMLAttributes<HTMLDivElement>
|
|
73
81
|
>(({ className, ...props }, ref) => (
|
|
74
|
-
<div
|
|
82
|
+
<div
|
|
83
|
+
ref={ref}
|
|
84
|
+
className={cn("p-4 [&:not(:first-child)]:pt-0", className)}
|
|
85
|
+
{...props}
|
|
86
|
+
/>
|
|
75
87
|
));
|
|
76
88
|
CardContent.displayName = "CardContent";
|
|
77
89
|
|
|
@@ -81,7 +93,7 @@ const CardFooter = React.forwardRef<
|
|
|
81
93
|
>(({ className, ...props }, ref) => (
|
|
82
94
|
<div
|
|
83
95
|
ref={ref}
|
|
84
|
-
className={cn("flex items-center p-
|
|
96
|
+
className={cn("flex flex-wrap items-center gap-2 p-4 [&:not(:first-child)]:pt-0", className)}
|
|
85
97
|
{...props}
|
|
86
98
|
/>
|
|
87
99
|
));
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Heading } from "./heading";
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: "Primitives/Heading",
|
|
6
|
+
component: Heading,
|
|
7
|
+
parameters: { layout: "padded" },
|
|
8
|
+
args: { variant: "page", children: "Activity across your workspace" },
|
|
9
|
+
argTypes: {
|
|
10
|
+
variant: { control: "select", options: ["display", "hero", "page", "section", "subsection", "eyebrow"] },
|
|
11
|
+
as: { control: "select", options: ["h1", "h2", "h3", "h4", "h5", "h6", "p"] },
|
|
12
|
+
},
|
|
13
|
+
} satisfies Meta<typeof Heading>;
|
|
14
|
+
export default meta;
|
|
15
|
+
type Story = StoryObj<typeof meta>;
|
|
16
|
+
|
|
17
|
+
export const Default: Story = {};
|
|
18
|
+
export const IndependentSemantics: Story = {
|
|
19
|
+
args: { variant: "page", as: "h2", children: "Page-sized title in a nested surface" },
|
|
20
|
+
};
|
|
21
|
+
export const Hierarchy: Story = {
|
|
22
|
+
render: () => <main className="space-y-6">
|
|
23
|
+
<Heading variant="eyebrow">Workspace</Heading>
|
|
24
|
+
<Heading variant="page">Activity</Heading>
|
|
25
|
+
<section className="space-y-4">
|
|
26
|
+
<Heading variant="section">Recent items</Heading>
|
|
27
|
+
<Heading variant="subsection">Build details</Heading>
|
|
28
|
+
<p className="text-muted-foreground">Eyebrows are labels, not extra headings.</p>
|
|
29
|
+
</section>
|
|
30
|
+
</main>,
|
|
31
|
+
play: ({ canvasElement }) => {
|
|
32
|
+
const tags = Array.from(canvasElement.querySelectorAll("h1,h2,h3,h4,h5,h6")).map((node) => node.tagName).join(",");
|
|
33
|
+
if (tags !== "H1,H2,H3") throw new Error(`Unexpected heading hierarchy: ${tags}`);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createRef } from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { Heading, type HeadingVariant } from "./heading";
|
|
5
|
+
|
|
6
|
+
const roles: [HeadingVariant, string][] = [
|
|
7
|
+
["display", "H1"], ["hero", "H2"], ["page", "H1"],
|
|
8
|
+
["section", "H2"], ["subsection", "H3"], ["eyebrow", "P"],
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
describe("Heading", () => {
|
|
12
|
+
it.each(roles)("gives %s its existing default element", (variant, tag) => {
|
|
13
|
+
render(<Heading variant={variant}>Title</Heading>);
|
|
14
|
+
expect(screen.getByText("Title").tagName).toBe(tag);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it.each(roles)("maps Sandbox's %s role without emitting an invalid ARIA role", (role, tag) => {
|
|
18
|
+
render(<Heading role={role}>Title</Heading>);
|
|
19
|
+
const title = screen.getByText("Title");
|
|
20
|
+
expect(title.tagName).toBe(tag);
|
|
21
|
+
expect(title).not.toHaveAttribute("role");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("changes semantics without changing the visual variant", () => {
|
|
25
|
+
const { rerender } = render(<Heading variant="page">Title</Heading>);
|
|
26
|
+
const classes = screen.getByRole("heading", { level: 1 }).className;
|
|
27
|
+
rerender(<Heading variant="page" as="h4">Title</Heading>);
|
|
28
|
+
expect(screen.getByRole("heading", { level: 4 }).className).toBe(classes);
|
|
29
|
+
expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("lets an explicit variant win over the existing visual-role input", () => {
|
|
33
|
+
render(<Heading variant="section" role="page">Title</Heading>);
|
|
34
|
+
expect(screen.getByRole("heading", { level: 2 })).not.toHaveAttribute("role");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("forwards native roles, refs and HTML attributes", () => {
|
|
38
|
+
const ref = createRef<HTMLElement>();
|
|
39
|
+
render(<Heading ref={ref} variant="eyebrow" as="div" role="note" id="scope" className="custom" data-scope="all">Scope</Heading>);
|
|
40
|
+
const note = screen.getByRole("note");
|
|
41
|
+
expect(ref.current).toBe(note);
|
|
42
|
+
expect(note).toHaveAttribute("id", "scope");
|
|
43
|
+
expect(note).toHaveAttribute("data-scope", "all");
|
|
44
|
+
expect(note).toHaveClass("custom");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("keeps a page, section and nested title in document order", () => {
|
|
48
|
+
render(<main>
|
|
49
|
+
<Heading variant="page">Activity</Heading>
|
|
50
|
+
<Heading variant="eyebrow">Last day</Heading>
|
|
51
|
+
<Heading variant="section">Recent activity</Heading>
|
|
52
|
+
<Heading variant="subsection">Details</Heading>
|
|
53
|
+
</main>);
|
|
54
|
+
expect(screen.getAllByRole("heading").map((node) => node.tagName)).toEqual(["H1", "H2", "H3"]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
/** Visual roles ported from sandbox-ui/src/primitives/heading.tsx. */
|
|
5
|
+
export type HeadingVariant =
|
|
6
|
+
| "display"
|
|
7
|
+
| "hero"
|
|
8
|
+
| "page"
|
|
9
|
+
| "section"
|
|
10
|
+
| "subsection"
|
|
11
|
+
| "eyebrow";
|
|
12
|
+
|
|
13
|
+
// Keep Brand's existing size, leading, tracking and weight tokens. The literal
|
|
14
|
+
// fallbacks are the existing Sandbox treatment, not a second type scale.
|
|
15
|
+
const VARIANT_CLASS: Record<HeadingVariant, string> = {
|
|
16
|
+
display:
|
|
17
|
+
"font-display font-bold text-foreground text-[length:var(--font-size-display,3rem)] leading-[var(--line-height-display,1.05)] tracking-[var(--tracking-tight,-0.02em)]",
|
|
18
|
+
hero: "font-display font-bold text-foreground text-[length:var(--font-size-hero,2.5rem)] leading-[1.1] tracking-[var(--tracking-tight,-0.02em)]",
|
|
19
|
+
page: "font-display font-semibold text-foreground text-[length:var(--font-size-3xl,1.875rem)] leading-[var(--line-height-heading,1.15)] tracking-[var(--tracking-tight,-0.02em)]",
|
|
20
|
+
section:
|
|
21
|
+
"font-display font-semibold text-foreground text-[length:var(--font-size-xl,1.25rem)] leading-[1.3] tracking-[var(--tracking-snug,-0.01em)]",
|
|
22
|
+
subsection:
|
|
23
|
+
"font-semibold text-foreground text-[length:var(--font-size-lg,1rem)] leading-[1.4]",
|
|
24
|
+
eyebrow:
|
|
25
|
+
"font-semibold uppercase text-muted-foreground text-[length:var(--font-size-sm,0.75rem)] leading-none tracking-[var(--tracking-wide,0.08em)]",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const DEFAULT_TAG: Record<HeadingVariant, React.ElementType> = {
|
|
29
|
+
display: "h1",
|
|
30
|
+
hero: "h2",
|
|
31
|
+
page: "h1",
|
|
32
|
+
section: "h2",
|
|
33
|
+
subsection: "h3",
|
|
34
|
+
eyebrow: "p",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export interface HeadingProps
|
|
38
|
+
extends Omit<React.HTMLAttributes<HTMLElement>, "role"> {
|
|
39
|
+
/** Visual treatment, independent of the rendered element. Defaults to section. */
|
|
40
|
+
variant?: HeadingVariant;
|
|
41
|
+
/** Semantic element; changing it does not change the visual variant. */
|
|
42
|
+
as?: React.ElementType;
|
|
43
|
+
/**
|
|
44
|
+
* Native ARIA role, or Sandbox's existing visual-role input. Only the six
|
|
45
|
+
* legacy visual values are consumed; they are never emitted as ARIA roles.
|
|
46
|
+
* Prefer variant for new code. An explicit variant wins over a legacy role.
|
|
47
|
+
*/
|
|
48
|
+
role?: React.AriaRole | HeadingVariant;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One renderer for page, section and card titles. Use at most one default
|
|
53
|
+
* display/page h1 per page; choose `as` according to the document hierarchy.
|
|
54
|
+
*/
|
|
55
|
+
const Heading = React.forwardRef<HTMLElement, HeadingProps>(
|
|
56
|
+
({ variant, role, as, className, ...props }, ref) => {
|
|
57
|
+
const legacyVariant =
|
|
58
|
+
role && Object.prototype.hasOwnProperty.call(VARIANT_CLASS, role)
|
|
59
|
+
? (role as HeadingVariant)
|
|
60
|
+
: undefined;
|
|
61
|
+
const visualVariant = variant ?? legacyVariant ?? "section";
|
|
62
|
+
const Tag = as ?? DEFAULT_TAG[visualVariant];
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Tag
|
|
66
|
+
ref={ref}
|
|
67
|
+
className={cn("[overflow-wrap:anywhere]", VARIANT_CLASS[visualVariant], className)}
|
|
68
|
+
role={legacyVariant ? undefined : role}
|
|
69
|
+
{...props}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
Heading.displayName = "Heading";
|
|
75
|
+
|
|
76
|
+
export { Heading };
|
package/src/primitives/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export {
|
|
|
9
9
|
CardDescription,
|
|
10
10
|
CardContent,
|
|
11
11
|
} from "./card";
|
|
12
|
+
export type { CardTitleProps } from "./card";
|
|
12
13
|
|
|
13
14
|
export {
|
|
14
15
|
Dialog,
|
|
@@ -72,6 +73,7 @@ export {
|
|
|
72
73
|
TableCell,
|
|
73
74
|
TableCaption,
|
|
74
75
|
} from "./table";
|
|
76
|
+
export type { TableProps } from "./table";
|
|
75
77
|
|
|
76
78
|
export { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs";
|
|
77
79
|
|
|
@@ -122,6 +124,12 @@ export type { CodeBlockProps, InlineCodeProps } from "./code-block";
|
|
|
122
124
|
|
|
123
125
|
export { ThemeToggle, useTheme } from "./theme-toggle";
|
|
124
126
|
|
|
127
|
+
export { Heading } from "./heading";
|
|
128
|
+
export type { HeadingProps, HeadingVariant } from "./heading";
|
|
129
|
+
|
|
130
|
+
export { PageShell } from "./page-shell";
|
|
131
|
+
export type { PageShellProps } from "./page-shell";
|
|
132
|
+
|
|
125
133
|
export { PageHeader } from "./page-header";
|
|
126
134
|
export type { PageHeaderProps } from "./page-header";
|
|
127
135
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { useId } from 'react'
|
|
2
3
|
import { Input, Textarea } from './input'
|
|
4
|
+
import { Label } from './label'
|
|
3
5
|
|
|
4
6
|
const meta: Meta<typeof Input> = {
|
|
5
7
|
title: 'Primitives/Input',
|
|
@@ -111,3 +113,29 @@ export const Overview: Story = {
|
|
|
111
113
|
</div>
|
|
112
114
|
),
|
|
113
115
|
}
|
|
116
|
+
|
|
117
|
+
export const FieldRelationships: Story = {
|
|
118
|
+
parameters: {
|
|
119
|
+
docs: {
|
|
120
|
+
description: {
|
|
121
|
+
story: 'Repeated labels must focus their own fields. Inspect accessible descriptions: caller help, hints, and errors coexist. The final field uses an explicit ID and the standalone shared Label.',
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
render: function FieldRelationships() {
|
|
126
|
+
const id = useId()
|
|
127
|
+
const descriptionId = `${id}-instructions`
|
|
128
|
+
return (
|
|
129
|
+
<div className="flex w-96 flex-col gap-4">
|
|
130
|
+
<p id={descriptionId}>Shared instructions: choose a descriptive name.</p>
|
|
131
|
+
<Input label="Name" hint="First name." aria-describedby={descriptionId} />
|
|
132
|
+
<Input label="Name" hint="Second name." error="A name is required." aria-describedby={descriptionId} />
|
|
133
|
+
<Textarea label="Name" hint="Explain the name." error="More detail is needed." aria-describedby={descriptionId} />
|
|
134
|
+
<div className="space-y-1.5">
|
|
135
|
+
<Label htmlFor={`${id}-explicit`}>External label</Label>
|
|
136
|
+
<Input id={`${id}-explicit`} hint="Explicit field ID is preserved." />
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
},
|
|
141
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { act, cleanup, render, screen, within } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { hydrateRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { renderToString } from "react-dom/server";
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { Input, Textarea } from "./input";
|
|
8
|
+
import { Label } from "./label";
|
|
9
|
+
|
|
10
|
+
afterEach(cleanup);
|
|
11
|
+
|
|
12
|
+
describe.each([
|
|
13
|
+
["Input", Input],
|
|
14
|
+
["Textarea", Textarea],
|
|
15
|
+
] as const)("%s relationships", (_name, Field) => {
|
|
16
|
+
it("gives repeated labels distinct, stable IDs and focuses the correct field", async () => {
|
|
17
|
+
const user = userEvent.setup();
|
|
18
|
+
const { rerender } = render(<><Field label="Name" /><Field label="Name" /></>);
|
|
19
|
+
const fields = screen.getAllByRole("textbox");
|
|
20
|
+
const ids = fields.map((field) => field.id);
|
|
21
|
+
expect(ids.every(Boolean)).toBe(true);
|
|
22
|
+
expect(new Set(ids).size).toBe(2);
|
|
23
|
+
expect(screen.getAllByLabelText("Name")).toEqual(fields);
|
|
24
|
+
for (const [index, label] of screen.getAllByText("Name", { selector: "label" }).entries()) {
|
|
25
|
+
expect(label).toHaveAttribute("for", ids[index]);
|
|
26
|
+
await user.click(label);
|
|
27
|
+
expect(fields[index]).toHaveFocus();
|
|
28
|
+
}
|
|
29
|
+
rerender(<><Field label="Renamed" /><Field label="Renamed" /></>);
|
|
30
|
+
expect(screen.getAllByRole("textbox").map((field) => field.id)).toEqual(ids);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("preserves an explicit ID and merges caller, hint and error descriptions without duplicates", () => {
|
|
34
|
+
render(<>
|
|
35
|
+
<p id="external">External instructions.</p>
|
|
36
|
+
<Field
|
|
37
|
+
id="profile"
|
|
38
|
+
label="Name"
|
|
39
|
+
hint="Use your full name."
|
|
40
|
+
error="Name is required."
|
|
41
|
+
aria-describedby={" external external\tprofile-hint "}
|
|
42
|
+
aria-invalid={false}
|
|
43
|
+
/>
|
|
44
|
+
</>);
|
|
45
|
+
const field = screen.getByRole("textbox", { name: "Name" });
|
|
46
|
+
expect(field).toHaveAttribute("id", "profile");
|
|
47
|
+
expect(field).toHaveAttribute("aria-describedby", "external profile-hint profile-error");
|
|
48
|
+
expect(screen.getByText("Use your full name.")).toHaveAttribute("id", "profile-hint");
|
|
49
|
+
expect(screen.getByText("Name is required.")).toHaveAttribute("id", "profile-error");
|
|
50
|
+
expect(field).toHaveAccessibleDescription("External instructions. Use your full name. Name is required.");
|
|
51
|
+
expect(field).toHaveAttribute("aria-invalid", "true");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("associates descriptions without a convenience label and removes stale relationships", () => {
|
|
55
|
+
const { rerender } = render(<Field aria-label="Name" hint="A hint" error="An error" />);
|
|
56
|
+
const field = screen.getByRole("textbox", { name: "Name" });
|
|
57
|
+
const id = field.id;
|
|
58
|
+
expect(id).not.toBe("");
|
|
59
|
+
expect(field).toHaveAccessibleDescription("A hint An error");
|
|
60
|
+
rerender(<Field aria-label="Name" hint="A hint" />);
|
|
61
|
+
expect(field.id).toBe(id);
|
|
62
|
+
expect(field).toHaveAttribute("aria-describedby", `${id}-hint`);
|
|
63
|
+
expect(field).not.toHaveAttribute("aria-invalid");
|
|
64
|
+
expect(document.getElementById(`${id}-error`)).toBeNull();
|
|
65
|
+
rerender(<Field aria-label="Name" />);
|
|
66
|
+
const bareField = screen.getByRole("textbox", { name: "Name" });
|
|
67
|
+
expect(bareField.id).toBe(id);
|
|
68
|
+
expect(bareField).not.toHaveAttribute("aria-describedby");
|
|
69
|
+
expect(document.getElementById(`${id}-hint`)).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("preserves the bare-element API and caller invalid states", () => {
|
|
73
|
+
const { container, rerender } = render(
|
|
74
|
+
<Field aria-label="Name" aria-describedby="external" aria-invalid="spelling" className="caller-class" />,
|
|
75
|
+
);
|
|
76
|
+
const field = screen.getByRole("textbox");
|
|
77
|
+
expect(container.firstElementChild).toBe(field);
|
|
78
|
+
expect(container.childElementCount).toBe(1);
|
|
79
|
+
expect(field).toHaveAttribute("aria-describedby", "external");
|
|
80
|
+
expect(field).toHaveAttribute("aria-invalid", "spelling");
|
|
81
|
+
expect(field).toHaveClass("caller-class");
|
|
82
|
+
rerender(<Field aria-label="Name" aria-invalid={false} />);
|
|
83
|
+
expect(field).toHaveAttribute("aria-invalid", "false");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("uses the existing Label treatment", () => {
|
|
87
|
+
render(<><Label className="block">Reference</Label><Field label="Name" /></>);
|
|
88
|
+
expect(screen.getByText("Name", { selector: "label" }).className)
|
|
89
|
+
.toBe(screen.getByText("Reference").className);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("keeps Input and Textarea IDs distinct even with the same label", () => {
|
|
94
|
+
render(<><Input label="Notes" /><Textarea label="Notes" /></>);
|
|
95
|
+
expect(new Set(screen.getAllByRole("textbox").map((field) => field.id)).size).toBe(2);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("exposes Input's error variant without requiring error text", () => {
|
|
99
|
+
render(<Input aria-label="Name" variant="error" />);
|
|
100
|
+
expect(screen.getByRole("textbox")).toHaveAttribute("aria-invalid", "true");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("forwards native field refs, values and change handlers", async () => {
|
|
104
|
+
const user = userEvent.setup();
|
|
105
|
+
const inputRef = React.createRef<HTMLInputElement>();
|
|
106
|
+
const textareaRef = React.createRef<HTMLTextAreaElement>();
|
|
107
|
+
const inputChange = vi.fn();
|
|
108
|
+
const textareaChange = vi.fn();
|
|
109
|
+
const { unmount } = render(<>
|
|
110
|
+
<Input ref={inputRef} label="Name" defaultValue="A" onChange={inputChange} />
|
|
111
|
+
<Textarea ref={textareaRef} label="Notes" defaultValue="B" onChange={textareaChange} />
|
|
112
|
+
</>);
|
|
113
|
+
expect(inputRef.current).toBe(screen.getByRole("textbox", { name: "Name" }));
|
|
114
|
+
expect(textareaRef.current).toBe(screen.getByRole("textbox", { name: "Notes" }));
|
|
115
|
+
await user.type(inputRef.current!, "lice");
|
|
116
|
+
await user.type(textareaRef.current!, "ody");
|
|
117
|
+
expect(inputRef.current).toHaveValue("Alice");
|
|
118
|
+
expect(textareaRef.current).toHaveValue("Body");
|
|
119
|
+
expect(inputChange).toHaveBeenCalled();
|
|
120
|
+
expect(textareaChange).toHaveBeenCalled();
|
|
121
|
+
unmount();
|
|
122
|
+
expect(inputRef.current).toBeNull();
|
|
123
|
+
expect(textareaRef.current).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("hydrates SSR IDs and all description relationships without mismatch", async () => {
|
|
127
|
+
const ui = <React.StrictMode>
|
|
128
|
+
<p id="external">External instructions.</p>
|
|
129
|
+
<Input label="Name" hint="First hint" />
|
|
130
|
+
<Input label="Name" error="Required" />
|
|
131
|
+
<Textarea label="Name" hint="Notes hint" error="Too short" aria-describedby="external" />
|
|
132
|
+
<Input id="explicit" label="Explicit" hint="Explicit hint" />
|
|
133
|
+
</React.StrictMode>;
|
|
134
|
+
const container = document.createElement("div");
|
|
135
|
+
container.innerHTML = renderToString(ui, { identifierPrefix: "fields-" });
|
|
136
|
+
document.body.appendChild(container);
|
|
137
|
+
const serverHTML = container.innerHTML;
|
|
138
|
+
const serverIds = within(container).getAllByRole("textbox").map((field) => field.id);
|
|
139
|
+
const recoverableError = vi.fn();
|
|
140
|
+
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
141
|
+
let root: Root | undefined;
|
|
142
|
+
try {
|
|
143
|
+
await act(async () => {
|
|
144
|
+
root = hydrateRoot(container, ui, { identifierPrefix: "fields-", onRecoverableError: recoverableError });
|
|
145
|
+
});
|
|
146
|
+
expect(new Set(serverIds).size).toBe(4);
|
|
147
|
+
expect(within(container).getAllByRole("textbox").map((field) => field.id)).toEqual(serverIds);
|
|
148
|
+
expect(container.innerHTML).toBe(serverHTML);
|
|
149
|
+
for (const field of within(container).getAllByRole("textbox")) {
|
|
150
|
+
for (const id of field.getAttribute("aria-describedby")!.split(" ")) {
|
|
151
|
+
expect(document.getElementById(id)).not.toBeNull();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
expect(recoverableError).not.toHaveBeenCalled();
|
|
155
|
+
expect(consoleError).not.toHaveBeenCalled();
|
|
156
|
+
} finally {
|
|
157
|
+
await act(async () => { root?.unmount(); });
|
|
158
|
+
consoleError.mockRestore();
|
|
159
|
+
container.remove();
|
|
160
|
+
}
|
|
161
|
+
});
|
package/src/primitives/input.tsx
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
1
3
|
import * as React from "react";
|
|
2
4
|
import { focusField, focusFieldInvalid } from "../lib/focus";
|
|
3
5
|
import { cn } from "../lib/utils";
|
|
6
|
+
import { Label } from "./label";
|
|
4
7
|
|
|
5
8
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
6
9
|
|
|
@@ -30,6 +33,11 @@ const inputVariants = cva(
|
|
|
30
33
|
}
|
|
31
34
|
);
|
|
32
35
|
|
|
36
|
+
// Only rendered descriptions are added; caller IDs and their order are retained.
|
|
37
|
+
function descriptionIds(...ids: Array<string | undefined>) {
|
|
38
|
+
return [...new Set(ids.join(" ").split(/\s+/).filter(Boolean))].join(" ") || undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
export interface InputProps
|
|
34
42
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
|
35
43
|
VariantProps<typeof inputVariants> {
|
|
@@ -40,10 +48,16 @@ export interface InputProps
|
|
|
40
48
|
|
|
41
49
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
42
50
|
(
|
|
43
|
-
{
|
|
51
|
+
{
|
|
52
|
+
className, type, variant, size, label, error, hint, id,
|
|
53
|
+
"aria-describedby": describedBy, "aria-invalid": invalid, ...props
|
|
54
|
+
},
|
|
44
55
|
ref,
|
|
45
56
|
) => {
|
|
46
|
-
const
|
|
57
|
+
const generatedId = React.useId();
|
|
58
|
+
const inputId = id ?? generatedId;
|
|
59
|
+
const hintId = hint ? `${inputId}-hint` : undefined;
|
|
60
|
+
const errorId = error ? `${inputId}-error` : undefined;
|
|
47
61
|
|
|
48
62
|
const input = (
|
|
49
63
|
<input
|
|
@@ -52,6 +66,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
|
52
66
|
className={cn(inputVariants({ variant: error ? "error" : variant, size, className }))}
|
|
53
67
|
ref={ref}
|
|
54
68
|
{...props}
|
|
69
|
+
aria-describedby={descriptionIds(describedBy, hintId, errorId)}
|
|
70
|
+
aria-invalid={error || variant === "error" ? true : invalid}
|
|
55
71
|
/>
|
|
56
72
|
);
|
|
57
73
|
|
|
@@ -59,19 +75,10 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
|
59
75
|
|
|
60
76
|
return (
|
|
61
77
|
<div className="w-full space-y-1.5">
|
|
62
|
-
{label &&
|
|
63
|
-
<label
|
|
64
|
-
htmlFor={inputId}
|
|
65
|
-
className="block font-medium text-foreground text-sm"
|
|
66
|
-
>
|
|
67
|
-
{label}
|
|
68
|
-
</label>
|
|
69
|
-
)}
|
|
78
|
+
{label && <Label htmlFor={inputId} className="block">{label}</Label>}
|
|
70
79
|
{input}
|
|
71
|
-
{error && <p className="text-[var(--surface-danger-text)] text-sm font-medium">{error}</p>}
|
|
72
|
-
{hint &&
|
|
73
|
-
<p className="text-[var(--text-dim)] text-sm">{hint}</p>
|
|
74
|
-
)}
|
|
80
|
+
{error && <p id={errorId} className="text-[var(--surface-danger-text)] text-sm font-medium">{error}</p>}
|
|
81
|
+
{hint && <p id={hintId} className="text-[var(--text-dim)] text-sm">{hint}</p>}
|
|
75
82
|
</div>
|
|
76
83
|
);
|
|
77
84
|
},
|
|
@@ -90,10 +97,16 @@ export interface TextareaProps
|
|
|
90
97
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
91
98
|
(
|
|
92
99
|
// `variant` is taken out so it never reaches the DOM element.
|
|
93
|
-
{
|
|
100
|
+
{
|
|
101
|
+
className, variant: _variant, label, error, hint, id,
|
|
102
|
+
"aria-describedby": describedBy, "aria-invalid": invalid, ...props
|
|
103
|
+
},
|
|
94
104
|
ref,
|
|
95
105
|
) => {
|
|
96
|
-
const
|
|
106
|
+
const generatedId = React.useId();
|
|
107
|
+
const textareaId = id ?? generatedId;
|
|
108
|
+
const hintId = hint ? `${textareaId}-hint` : undefined;
|
|
109
|
+
const errorId = error ? `${textareaId}-error` : undefined;
|
|
97
110
|
|
|
98
111
|
const textarea = (
|
|
99
112
|
<textarea
|
|
@@ -108,6 +121,8 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
108
121
|
)}
|
|
109
122
|
ref={ref}
|
|
110
123
|
{...props}
|
|
124
|
+
aria-describedby={descriptionIds(describedBy, hintId, errorId)}
|
|
125
|
+
aria-invalid={error ? true : invalid}
|
|
111
126
|
/>
|
|
112
127
|
);
|
|
113
128
|
|
|
@@ -115,19 +130,10 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
115
130
|
|
|
116
131
|
return (
|
|
117
132
|
<div className="w-full space-y-1.5">
|
|
118
|
-
{label &&
|
|
119
|
-
<label
|
|
120
|
-
htmlFor={textareaId}
|
|
121
|
-
className="block font-medium text-muted-foreground text-sm"
|
|
122
|
-
>
|
|
123
|
-
{label}
|
|
124
|
-
</label>
|
|
125
|
-
)}
|
|
133
|
+
{label && <Label htmlFor={textareaId} className="block">{label}</Label>}
|
|
126
134
|
{textarea}
|
|
127
|
-
{error && <p className="text-[var(--surface-danger-text)] text-sm">{error}</p>}
|
|
128
|
-
{hint &&
|
|
129
|
-
<p className="text-[var(--text-dim)] text-sm">{hint}</p>
|
|
130
|
-
)}
|
|
135
|
+
{error && <p id={errorId} className="text-[var(--surface-danger-text)] text-sm">{error}</p>}
|
|
136
|
+
{hint && <p id={hintId} className="text-[var(--text-dim)] text-sm">{hint}</p>}
|
|
131
137
|
</div>
|
|
132
138
|
);
|
|
133
139
|
},
|