@tangle-network/ui 11.32.2 → 11.34.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 +23 -0
- package/dist/editor/document-editor-pane.d.ts +7 -1
- package/dist/editor/document-editor-pane.js +27 -12
- package/dist/editor/markdown-document-editor.js +30 -12
- package/dist/editor/source-markdown-editor.js +256 -0
- package/dist/editor/source-markdown.js +495 -0
- package/dist/files/file-artifact-pane.d.ts +2 -0
- package/dist/files/file-artifact-pane.js +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/lib/control-presentation.js +2 -1
- package/dist/lib/focus.d.ts +5 -1
- package/dist/lib/focus.js +6 -1
- package/dist/lib/interaction.d.ts +41 -0
- package/dist/lib/interaction.js +42 -0
- package/dist/markdown/markdown-table.js +1 -1
- package/dist/primitives/button.js +19 -18
- package/dist/primitives/card.d.ts +14 -1
- package/dist/primitives/card.js +15 -5
- package/dist/primitives/dropdown-menu.js +7 -6
- package/dist/primitives/segmented-control.d.ts +5 -1
- package/dist/primitives/segmented-control.js +12 -5
- package/dist/primitives/select.js +4 -3
- package/dist/primitives/tabs.js +4 -3
- package/dist/run/inline-thinking-item.d.ts +7 -1
- package/dist/run/inline-thinking-item.js +6 -5
- package/dist/utils/format.d.ts +6 -0
- package/dist/utils/format.js +9 -1
- package/dist/utils.d.ts +3 -2
- package/dist/utils.js +3 -2
- package/package.json +2 -2
- package/src/editor/document-editor-pane.tsx +54 -16
- package/src/editor/markdown-document-editor.tsx +33 -18
- package/src/editor/source-markdown-editor.test.tsx +156 -0
- package/src/editor/source-markdown-editor.ts +296 -0
- package/src/editor/source-markdown.fixtures.ts +90 -0
- package/src/editor/source-markdown.test.ts +235 -0
- package/src/editor/source-markdown.ts +627 -0
- package/src/files/file-artifact-pane.test.tsx +38 -3
- package/src/files/file-artifact-pane.tsx +3 -0
- package/src/lib/control-presentation.ts +10 -4
- package/src/lib/focus.ts +7 -1
- package/src/lib/interaction.ts +64 -0
- package/src/markdown/markdown-table.test.tsx +9 -0
- package/src/markdown/markdown-table.tsx +5 -1
- package/src/primitives/button.tsx +29 -10
- package/src/primitives/card.tsx +23 -6
- package/src/primitives/control-presentation.test.tsx +6 -3
- package/src/primitives/date-picker.test.tsx +1 -1
- package/src/primitives/dropdown-menu.tsx +15 -10
- package/src/primitives/interaction-states.test.tsx +285 -0
- package/src/primitives/segmented-control.test.tsx +4 -2
- package/src/primitives/segmented-control.tsx +13 -14
- package/src/primitives/select.tsx +11 -5
- package/src/primitives/tabs.tsx +7 -3
- package/src/run/inline-thinking-item.test.tsx +29 -0
- package/src/run/inline-thinking-item.tsx +11 -4
- package/src/utils/format.test.ts +16 -1
- package/src/utils/format.ts +17 -0
- package/src/utils/index.ts +10 -1
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { compile } from "tailwindcss";
|
|
4
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { focusFieldWithin } from "../lib/focus";
|
|
6
|
+
import {
|
|
7
|
+
ixControl,
|
|
8
|
+
ixControlAfter,
|
|
9
|
+
ixControlSoft,
|
|
10
|
+
ixField,
|
|
11
|
+
ixRow,
|
|
12
|
+
ixSelected,
|
|
13
|
+
ixTabUnderline,
|
|
14
|
+
} from "../lib/interaction";
|
|
15
|
+
import { Button } from "./button";
|
|
16
|
+
import { Card, CardDescription } from "./card";
|
|
17
|
+
import {
|
|
18
|
+
DropdownMenu,
|
|
19
|
+
DropdownMenuCheckboxItem,
|
|
20
|
+
DropdownMenuContent,
|
|
21
|
+
DropdownMenuItem,
|
|
22
|
+
DropdownMenuRadioGroup,
|
|
23
|
+
DropdownMenuRadioItem,
|
|
24
|
+
DropdownMenuSub,
|
|
25
|
+
DropdownMenuSubTrigger,
|
|
26
|
+
DropdownMenuTrigger,
|
|
27
|
+
} from "./dropdown-menu";
|
|
28
|
+
import { Input, Textarea } from "./input";
|
|
29
|
+
import { SegmentedControl } from "./segmented-control";
|
|
30
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
|
|
31
|
+
import { Tabs, TabsList, TabsTrigger } from "./tabs";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Every primitive draws its interaction states from one helper in
|
|
35
|
+
* lib/interaction.ts (ROOT-SPEC section b). This is the cheap static guard:
|
|
36
|
+
* each primitive's rendered className carries every class of its helper, so a
|
|
37
|
+
* later edit or a tailwind-merge conflict cannot silently drop a state. The
|
|
38
|
+
* second suite compiles each helper with Tailwind to prove the classes emit the
|
|
39
|
+
* declarations and selectors the states need.
|
|
40
|
+
*/
|
|
41
|
+
afterEach(cleanup);
|
|
42
|
+
beforeAll(() => {
|
|
43
|
+
// jsdom lacks the layout APIs Radix Select and Menu call while opening.
|
|
44
|
+
Element.prototype.scrollIntoView ??= vi.fn();
|
|
45
|
+
Element.prototype.hasPointerCapture ??= vi.fn(() => false);
|
|
46
|
+
Element.prototype.releasePointerCapture ??= vi.fn();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const tokens = (helper: string) => helper.split(/\s+/).filter(Boolean);
|
|
50
|
+
const classes = (el: Element) => new Set(el.className.split(/\s+/));
|
|
51
|
+
|
|
52
|
+
/** Every class of every helper is on the element. */
|
|
53
|
+
function expectHelpers(el: Element, ...helpers: string[]) {
|
|
54
|
+
const have = classes(el);
|
|
55
|
+
for (const helper of helpers) {
|
|
56
|
+
for (const token of tokens(helper)) expect(have, `${el.tagName} is missing ${token}`).toContain(token);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The glow only strong controls get. */
|
|
61
|
+
const HOVER_GLOW = "hover:shadow-[var(--ix-hover)]";
|
|
62
|
+
/** focusRing, which every control keeps exactly. */
|
|
63
|
+
const FOCUS_RING = [
|
|
64
|
+
"focus-visible:outline-1",
|
|
65
|
+
"focus-visible:outline-[var(--focus-border)]",
|
|
66
|
+
"focus-visible:ring-4",
|
|
67
|
+
"focus-visible:ring-[var(--focus-halo)]",
|
|
68
|
+
];
|
|
69
|
+
/** focusField's `:focus` part, which every field keeps exactly. */
|
|
70
|
+
const FOCUS_FIELD = ["focus:outline-hidden", "focus:border-[var(--focus-border)]", "focus:ring-3", "focus:ring-[var(--focus-halo)]"];
|
|
71
|
+
|
|
72
|
+
describe("each primitive renders its interaction helper", () => {
|
|
73
|
+
it.each(["default", "destructive", "secondary", "outline", "sandbox"] as const)(
|
|
74
|
+
"Button %s takes ixControl and keeps the focus ring",
|
|
75
|
+
(variant) => {
|
|
76
|
+
render(<Button variant={variant}>Go</Button>);
|
|
77
|
+
const button = screen.getByRole("button");
|
|
78
|
+
expectHelpers(button, ixControl);
|
|
79
|
+
for (const token of FOCUS_RING) expect(classes(button)).toContain(token);
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
it.each(["ghost", "link", "bare"] as const)("Button %s takes ixControlSoft and never the hover glow", (variant) => {
|
|
84
|
+
render(<Button variant={variant}>Go</Button>);
|
|
85
|
+
const button = screen.getByRole("button");
|
|
86
|
+
expectHelpers(button, ixControlSoft);
|
|
87
|
+
expect(classes(button)).not.toContain(HOVER_GLOW);
|
|
88
|
+
expect(classes(button)).not.toContain("hover:bg-muted");
|
|
89
|
+
for (const token of FOCUS_RING) expect(classes(button)).toContain(token);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it.each(["outline", "ghost", "bare"] as const)("a %s toggle Button (aria-pressed) adds ixControl and ixSelected", (variant) => {
|
|
93
|
+
render(<Button variant={variant} aria-pressed>Bold</Button>);
|
|
94
|
+
expectHelpers(screen.getByRole("button"), ixControl, ixSelected);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("Input and Textarea take ixField with focusField's focus border and halo", () => {
|
|
98
|
+
render(<><Input aria-label="Name" /><Textarea aria-label="Notes" /></>);
|
|
99
|
+
for (const field of [screen.getByLabelText("Name"), screen.getByLabelText("Notes")]) {
|
|
100
|
+
expectHelpers(field, ixField);
|
|
101
|
+
for (const token of FOCUS_FIELD) expect(classes(field)).toContain(token);
|
|
102
|
+
expect(classes(field)).not.toContain("border-border");
|
|
103
|
+
expect(classes(field)).not.toContain("hover:border-[var(--border-strong)]");
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("focusFieldWithin is a field: ixField plus the has-focus ring, without the neutral hover", () => {
|
|
108
|
+
expect(tokens(focusFieldWithin)).toEqual(expect.arrayContaining(tokens(ixField)));
|
|
109
|
+
expect(focusFieldWithin).toContain("has-[");
|
|
110
|
+
expect(focusFieldWithin).toContain(":focus]:ring-[var(--focus-halo)]");
|
|
111
|
+
expect(tokens(focusFieldWithin)).not.toContain("hover:border-[var(--border-strong)]");
|
|
112
|
+
expect(tokens(focusFieldWithin)).not.toContain("border-border");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("SelectTrigger is a field that keeps its well and an after-click ring while open", () => {
|
|
116
|
+
render(<Select><SelectTrigger aria-label="Status"><SelectValue /></SelectTrigger></Select>);
|
|
117
|
+
const trigger = screen.getByRole("combobox");
|
|
118
|
+
expectHelpers(trigger, ixField, ixControlAfter);
|
|
119
|
+
for (const token of FOCUS_FIELD) expect(classes(trigger)).toContain(token);
|
|
120
|
+
expect(classes(trigger)).toContain("data-[state=open]:shadow-[var(--ix-after)]");
|
|
121
|
+
expect(classes(trigger)).toContain("data-[state=open]:border-[var(--ix-hover-border)]");
|
|
122
|
+
// The open state must not repaint the well.
|
|
123
|
+
expect([...classes(trigger)].filter((c) => c.startsWith("data-[state=open]:bg-"))).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("SelectContent paints the popover level and SelectItem takes ixRow", async () => {
|
|
127
|
+
render(
|
|
128
|
+
<Select open value="a">
|
|
129
|
+
<SelectTrigger aria-label="Status"><SelectValue /></SelectTrigger>
|
|
130
|
+
<SelectContent><SelectItem value="a">Active</SelectItem><SelectItem value="b">Paused</SelectItem></SelectContent>
|
|
131
|
+
</Select>,
|
|
132
|
+
);
|
|
133
|
+
const listbox = await screen.findByRole("listbox");
|
|
134
|
+
const content = listbox.closest("[data-radix-select-viewport]")?.parentElement ?? listbox;
|
|
135
|
+
expect(classes(content)).toContain("bg-popover");
|
|
136
|
+
expect(classes(content)).toContain("shadow-[var(--edge-highlight),var(--shadow-card)]");
|
|
137
|
+
expect(classes(content)).not.toContain("bg-card");
|
|
138
|
+
for (const option of screen.getAllByRole("option")) {
|
|
139
|
+
expectHelpers(option, ixRow);
|
|
140
|
+
expect(classes(option)).not.toContain("focus:bg-[var(--accent-surface-strong)]");
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("DropdownMenu content paints the popover level and every item kind takes ixRow", async () => {
|
|
145
|
+
render(
|
|
146
|
+
<DropdownMenu open>
|
|
147
|
+
<DropdownMenuTrigger>Menu</DropdownMenuTrigger>
|
|
148
|
+
<DropdownMenuContent>
|
|
149
|
+
<DropdownMenuItem>Rename</DropdownMenuItem>
|
|
150
|
+
<DropdownMenuCheckboxItem checked>Pinned</DropdownMenuCheckboxItem>
|
|
151
|
+
<DropdownMenuRadioGroup value="a"><DropdownMenuRadioItem value="a">Newest</DropdownMenuRadioItem></DropdownMenuRadioGroup>
|
|
152
|
+
<DropdownMenuSub><DropdownMenuSubTrigger>Move to</DropdownMenuSubTrigger></DropdownMenuSub>
|
|
153
|
+
</DropdownMenuContent>
|
|
154
|
+
</DropdownMenu>,
|
|
155
|
+
);
|
|
156
|
+
const menu = await screen.findByRole("menu");
|
|
157
|
+
expect(classes(menu)).toContain("bg-popover");
|
|
158
|
+
expect(classes(menu)).toContain("shadow-[var(--edge-highlight),var(--shadow-card)]");
|
|
159
|
+
expect(classes(menu)).not.toContain("bg-card");
|
|
160
|
+
const items = [
|
|
161
|
+
screen.getByRole("menuitem", { name: "Rename" }),
|
|
162
|
+
screen.getByRole("menuitemcheckbox", { name: "Pinned" }),
|
|
163
|
+
screen.getByRole("menuitemradio", { name: "Newest" }),
|
|
164
|
+
screen.getByRole("menuitem", { name: "Move to" }),
|
|
165
|
+
];
|
|
166
|
+
for (const item of items) {
|
|
167
|
+
expectHelpers(item, ixRow);
|
|
168
|
+
expect(classes(item)).not.toContain("focus:bg-muted/50");
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("Card is a ladder level with a shadow; hover and interactive cards take ixControl", () => {
|
|
173
|
+
render(
|
|
174
|
+
<>
|
|
175
|
+
<Card data-testid="plain" />
|
|
176
|
+
<Card data-testid="hover" hover />
|
|
177
|
+
<Card data-testid="interactive" interactive />
|
|
178
|
+
<CardDescription>Guests, staff and vendors message your business on WhatsApp.</CardDescription>
|
|
179
|
+
</>,
|
|
180
|
+
);
|
|
181
|
+
const plain = screen.getByTestId("plain");
|
|
182
|
+
expect(plain).toHaveAttribute("data-slot", "card");
|
|
183
|
+
expect(classes(plain)).toContain("shadow-[var(--edge-highlight),var(--shadow-card)]");
|
|
184
|
+
expect(classes(plain)).not.toContain(HOVER_GLOW);
|
|
185
|
+
for (const id of ["hover", "interactive"]) {
|
|
186
|
+
const card = screen.getByTestId(id);
|
|
187
|
+
expectHelpers(card, ixControl);
|
|
188
|
+
expect(card).not.toHaveAttribute("interactive");
|
|
189
|
+
expect(classes(card)).not.toContain("hover:border-[var(--border-strong)]");
|
|
190
|
+
}
|
|
191
|
+
expect(classes(screen.getByText(/Guests, staff/))).toContain("line-clamp-2");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("SegmentedControl row: a well track, soft items, the selected state on aria-checked", () => {
|
|
195
|
+
const options = [{ value: "a", label: "A–Z" }, { value: "b", label: "Recent" }];
|
|
196
|
+
render(<SegmentedControl aria-label="Sort" value="a" onValueChange={() => {}} options={options} />);
|
|
197
|
+
const track = screen.getByRole("radiogroup");
|
|
198
|
+
expect(classes(track)).toContain("bg-[var(--well)]");
|
|
199
|
+
expect(classes(track)).toContain("border-[var(--field-border)]");
|
|
200
|
+
expect(classes(track)).not.toContain("bg-card");
|
|
201
|
+
for (const radio of screen.getAllByRole("radio")) {
|
|
202
|
+
expectHelpers(radio, ixControlSoft, ixSelected);
|
|
203
|
+
for (const token of FOCUS_RING) expect(classes(radio)).toContain(token);
|
|
204
|
+
expect(classes(radio)).not.toContain("bg-[var(--accent-surface)]");
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("SegmentedControl tabs: the underline helper and a transparent border slot", () => {
|
|
209
|
+
const options = [{ value: "a", label: "Team" }, { value: "b", label: "Roles" }];
|
|
210
|
+
render(<SegmentedControl aria-label="Section" variant="tabs" value="a" onValueChange={() => {}} options={options} />);
|
|
211
|
+
for (const radio of screen.getAllByRole("radio")) {
|
|
212
|
+
expectHelpers(radio, ixTabUnderline);
|
|
213
|
+
expect(classes(radio)).toContain("border-transparent");
|
|
214
|
+
expect(classes(radio)).not.toContain("border-[var(--accent-text)]");
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it.each([
|
|
219
|
+
["default", [ixControlSoft, ixSelected]],
|
|
220
|
+
["pills", [ixControlSoft, ixSelected]],
|
|
221
|
+
["underline", [ixTabUnderline]],
|
|
222
|
+
] as const)("TabsTrigger %s takes its helper", (variant, helpers) => {
|
|
223
|
+
render(
|
|
224
|
+
<Tabs defaultValue="a">
|
|
225
|
+
<TabsList variant={variant}>
|
|
226
|
+
<TabsTrigger variant={variant} value="a">Overview</TabsTrigger>
|
|
227
|
+
<TabsTrigger variant={variant} value="b">Activity</TabsTrigger>
|
|
228
|
+
</TabsList>
|
|
229
|
+
</Tabs>,
|
|
230
|
+
);
|
|
231
|
+
for (const tab of screen.getAllByRole("tab")) {
|
|
232
|
+
expectHelpers(tab, ...helpers);
|
|
233
|
+
for (const token of FOCUS_RING) expect(classes(tab)).toContain(token);
|
|
234
|
+
expect([...classes(tab)]).not.toEqual(expect.arrayContaining(["data-[state=active]:bg-background"]));
|
|
235
|
+
expect(classes(tab)).not.toContain("data-[state=active]:border-primary");
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("Tailwind compiles every interaction class to the state it names", () => {
|
|
241
|
+
/** A fresh compiler per class, so each class's CSS is read on its own. */
|
|
242
|
+
async function cssFor(candidate: string) {
|
|
243
|
+
const compiler = await compile("@tailwind utilities;");
|
|
244
|
+
return compiler.build([candidate]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const all = [...new Set([ixControl, ixControlAfter, ixControlSoft, ixField, ixSelected, ixTabUnderline, ixRow].flatMap(tokens))];
|
|
248
|
+
|
|
249
|
+
it.each(all)("%s emits a rule", async (candidate) => {
|
|
250
|
+
expect(await cssFor(candidate)).toMatch(/\{[^}]*:[^}]*\}/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("composes the hover and press glows with the focus ring instead of replacing it", async () => {
|
|
254
|
+
for (const [candidate, value] of [
|
|
255
|
+
["hover:shadow-[var(--ix-hover)]", "var(--ix-hover)"],
|
|
256
|
+
["active:shadow-[var(--ix-press)]", "var(--ix-press)"],
|
|
257
|
+
]) {
|
|
258
|
+
const css = await cssFor(candidate);
|
|
259
|
+
expect(css).toContain(`--tw-shadow: ${value}`);
|
|
260
|
+
expect(css).toContain("var(--tw-ring-shadow), var(--tw-shadow)");
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("keys the after-click ring on mouse focus only", async () => {
|
|
265
|
+
const css = await cssFor("focus:not-focus-visible:shadow-[var(--ix-after)]");
|
|
266
|
+
expect(css).toContain(":focus:not(:focus-visible)");
|
|
267
|
+
expect(css).toContain("--tw-shadow: var(--ix-after)");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("reads the field fill and border from the surface context", async () => {
|
|
271
|
+
expect(await cssFor("bg-[var(--field-surface,var(--well))]")).toContain("background-color: var(--field-surface,var(--well))");
|
|
272
|
+
expect(await cssFor("border-[var(--field-border)]")).toContain("border-color: var(--field-border)");
|
|
273
|
+
expect(await cssFor("hover:border-[var(--ix-hover-border)]")).toContain("border-color: var(--ix-hover-border)");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("draws selection on aria-pressed, aria-checked, aria-current, data-state on and active", async () => {
|
|
277
|
+
const css = await cssFor("aria-[current=page]:shadow-[inset_0_0_0_1px_var(--ix-sel-ring)]");
|
|
278
|
+
expect(css).toContain('[aria-current="page"]');
|
|
279
|
+
expect(css).toContain("inset 0 0 0 1px");
|
|
280
|
+
expect(await cssFor("aria-pressed:bg-[var(--ix-sel-bg)]")).toContain('[aria-pressed="true"]');
|
|
281
|
+
expect(await cssFor("aria-checked:bg-[var(--ix-sel-bg)]")).toContain('[aria-checked="true"]');
|
|
282
|
+
expect(await cssFor("data-[state=active]:bg-[var(--ix-sel-bg)]")).toContain('[data-state="active"]');
|
|
283
|
+
expect(await cssFor("data-[highlighted]:bg-[var(--ix-sel-bg)]")).toContain("[data-highlighted]");
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -271,7 +271,9 @@ describe("SegmentedControl", () => {
|
|
|
271
271
|
const radiogroup = screen.getByRole("radiogroup")
|
|
272
272
|
expect(radiogroup.className).toContain("flex-wrap")
|
|
273
273
|
expect(radiogroup.className).toContain("rounded-lg")
|
|
274
|
-
|
|
274
|
+
// The row track is a field well, so it steps off whatever plane holds it.
|
|
275
|
+
expect(radiogroup.className).toContain("bg-[var(--well)]")
|
|
276
|
+
expect(radiogroup.className).toContain("border-[var(--field-border)]")
|
|
275
277
|
})
|
|
276
278
|
|
|
277
279
|
it("applies tabs variant classes with nowrap", () => {
|
|
@@ -287,7 +289,7 @@ describe("SegmentedControl", () => {
|
|
|
287
289
|
expect(radiogroup.className).toContain("flex-nowrap")
|
|
288
290
|
expect(radiogroup.className).toContain("overflow-x-auto")
|
|
289
291
|
expect(radiogroup.className).toContain("border-b")
|
|
290
|
-
expect(radiogroup.className).not.toContain("bg-
|
|
292
|
+
expect(radiogroup.className).not.toContain("bg-[var(--well)]")
|
|
291
293
|
})
|
|
292
294
|
|
|
293
295
|
it("passes aria-label to the radiogroup", () => {
|
|
@@ -5,6 +5,7 @@ declare const process: { env: { NODE_ENV?: string } } | undefined
|
|
|
5
5
|
import * as React from "react"
|
|
6
6
|
import { touchTarget } from "../lib/control-presentation"
|
|
7
7
|
import { focusRing, focusRingInset } from "../lib/focus"
|
|
8
|
+
import { ixControlSoft, ixSelected, ixTabUnderline } from "../lib/interaction"
|
|
8
9
|
import { cn } from "../lib/utils"
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -19,7 +20,11 @@ import { cn } from "../lib/utils"
|
|
|
19
20
|
* console warning fires when both are omitted.
|
|
20
21
|
*
|
|
21
22
|
* Design rules baked into the default variant:
|
|
22
|
-
* -
|
|
23
|
+
* - The row track is a field well (`--well`, `--field-border`), so the
|
|
24
|
+
* control reads as one inset group on whatever plane it sits on.
|
|
25
|
+
* - Only the SELECTED segment shows a surface colour + accent text: the
|
|
26
|
+
* shared selected state (`ixSelected`: tint plus an inset ring). A tab
|
|
27
|
+
* shows it as an underline instead (`ixTabUnderline`).
|
|
23
28
|
* - Unselected segments have NO background — they're plain-text labels
|
|
24
29
|
* that darken on hover. This keeps the selected segment as the
|
|
25
30
|
* single visual anchor instead of the whole group competing with
|
|
@@ -160,7 +165,7 @@ export function SegmentedControl<T extends string = string>({
|
|
|
160
165
|
className={cn(
|
|
161
166
|
"flex gap-1",
|
|
162
167
|
variant === "row" &&
|
|
163
|
-
"flex-wrap items-center rounded-lg border border-border bg-
|
|
168
|
+
"flex-wrap items-center rounded-lg border border-[var(--field-border)] bg-[var(--well)] p-1",
|
|
164
169
|
variant === "tabs" &&
|
|
165
170
|
["relative flex-nowrap items-end border-b border-border pb-0 overflow-x-auto", tabEdgeFade],
|
|
166
171
|
className,
|
|
@@ -186,19 +191,13 @@ export function SegmentedControl<T extends string = string>({
|
|
|
186
191
|
}}
|
|
187
192
|
className={cn(
|
|
188
193
|
"relative inline-flex items-center gap-2 whitespace-nowrap text-sm font-medium transition-[color,background-color,border-color,box-shadow]",
|
|
189
|
-
variant === "row" && ["rounded-md px-3 py-1.5", focusRing],
|
|
194
|
+
variant === "row" && ["rounded-md border border-transparent px-3 py-1.5", focusRing, ixControlSoft, ixSelected],
|
|
190
195
|
// The strip scrolls horizontally, which would clip an outer ring.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
//
|
|
194
|
-
// compete
|
|
195
|
-
active
|
|
196
|
-
? variant === "row"
|
|
197
|
-
? "border border-transparent bg-[var(--accent-surface)] text-[var(--accent-text)]"
|
|
198
|
-
: "border-[var(--accent-text)] text-[var(--accent-text)]"
|
|
199
|
-
: variant === "row"
|
|
200
|
-
? "border border-transparent text-muted-foreground hover:text-foreground"
|
|
201
|
-
: "border-transparent text-muted-foreground hover:text-foreground",
|
|
196
|
+
// The border-b-2 slot stays transparent: the underline is ixTabUnderline's inset shadow.
|
|
197
|
+
variant === "tabs" && ["shrink-0 rounded-none border-b-2 border-transparent -mb-px px-4 py-2", touchTarget, focusRingInset, ixTabUnderline],
|
|
198
|
+
// Selection (aria-checked) is the only filled state; unselected
|
|
199
|
+
// segments stay transparent so they don't compete with it.
|
|
200
|
+
active ? "text-[var(--accent-text)]" : "text-muted-foreground hover:text-foreground",
|
|
202
201
|
)}
|
|
203
202
|
>
|
|
204
203
|
<span>{option.label}</span>
|
|
@@ -4,6 +4,7 @@ import * as SelectPrimitive from "@radix-ui/react-select";
|
|
|
4
4
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
|
5
5
|
import * as React from "react";
|
|
6
6
|
import { controlSizes, fieldPresentation, fieldSizes, fieldTouchText } from "../lib/control-presentation";
|
|
7
|
+
import { ixControlAfter, ixRow } from "../lib/interaction";
|
|
7
8
|
import { cn } from "../lib/utils";
|
|
8
9
|
|
|
9
10
|
const Select = SelectPrimitive.Root;
|
|
@@ -22,14 +23,18 @@ const SelectTrigger = React.forwardRef<
|
|
|
22
23
|
<SelectPrimitive.Trigger
|
|
23
24
|
ref={ref}
|
|
24
25
|
className={cn(
|
|
25
|
-
"flex w-full items-center justify-between gap-2 whitespace-nowrap border py-1
|
|
26
|
+
"flex w-full items-center justify-between gap-2 whitespace-nowrap border py-1 placeholder:text-[var(--text-dim)] disabled:cursor-not-allowed disabled:opacity-50",
|
|
27
|
+
// A field (ixField, via fieldPresentation) that keeps a soft ring after a
|
|
28
|
+
// mouse click, like every control.
|
|
26
29
|
fieldPresentation,
|
|
30
|
+
ixControlAfter,
|
|
27
31
|
fieldTouchText,
|
|
28
32
|
// A long value ends in an ellipsis before the chevron: line-clamp cannot
|
|
29
33
|
// ellipsize a nowrap line, so it clipped mid-glyph under the icon.
|
|
30
34
|
"data-[placeholder]:text-[var(--text-dim)] [&>span]:min-w-0 [&>span]:truncate [&>span]:text-left",
|
|
31
|
-
//
|
|
32
|
-
|
|
35
|
+
// Open: the after-click ring stays while the menu is up, and the field
|
|
36
|
+
// keeps its well fill so it still reads as the field it is.
|
|
37
|
+
"data-[state=open]:border-[var(--ix-hover-border)] data-[state=open]:shadow-[var(--ix-after)] [&[data-state=open]>svg]:rotate-180 [&>svg]:transition-transform",
|
|
33
38
|
size === "compact" || size === "touch" ? cn(fieldSizes.md, controlSizes[size]) : fieldSizes[size === "default" ? "md" : size],
|
|
34
39
|
className,
|
|
35
40
|
)}
|
|
@@ -86,7 +91,7 @@ const SelectContent = React.forwardRef<
|
|
|
86
91
|
<SelectPrimitive.Content
|
|
87
92
|
ref={ref}
|
|
88
93
|
className={cn(
|
|
89
|
-
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-
|
|
94
|
+
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-[var(--edge-highlight),var(--shadow-card)] data-[state=closed]:animate-out data-[state=open]:animate-in",
|
|
90
95
|
position === "popper" &&
|
|
91
96
|
"data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1",
|
|
92
97
|
className,
|
|
@@ -129,7 +134,8 @@ const SelectItem = React.forwardRef<
|
|
|
129
134
|
<SelectPrimitive.Item
|
|
130
135
|
ref={ref}
|
|
131
136
|
className={cn(
|
|
132
|
-
"relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pr-8 pl-2 text-sm outline-none transition-
|
|
137
|
+
"relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pr-8 pl-2 text-sm outline-none transition-[color,background-color,box-shadow] focus:text-foreground data-[state=checked]:font-medium data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
138
|
+
ixRow,
|
|
133
139
|
className,
|
|
134
140
|
)}
|
|
135
141
|
{...props}
|
package/src/primitives/tabs.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import { focusRing } from "../lib/focus";
|
|
4
|
+
import { ixControlSoft, ixSelected, ixTabUnderline } from "../lib/interaction";
|
|
4
5
|
import { cn } from "../lib/utils";
|
|
5
6
|
|
|
6
7
|
const Tabs = TabsPrimitive.Root;
|
|
@@ -34,13 +35,16 @@ const TabsTrigger = React.forwardRef<
|
|
|
34
35
|
variant?: "default" | "pills" | "underline";
|
|
35
36
|
}
|
|
36
37
|
>(({ className, variant = "default", ...props }, ref) => {
|
|
38
|
+
// Selection is the shared selected state (lib/interaction.ts): a tint and an
|
|
39
|
+
// inset ring for default and pills, a 2px underline for underline, whose
|
|
40
|
+
// border-b-2 slot stays transparent.
|
|
37
41
|
const variants = {
|
|
38
42
|
default:
|
|
39
|
-
`inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all ${focusRing} disabled:pointer-events-none disabled:opacity-50
|
|
43
|
+
`inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-all ${focusRing} disabled:pointer-events-none disabled:opacity-50 ${ixControlSoft} ${ixSelected}`,
|
|
40
44
|
pills:
|
|
41
|
-
`inline-flex items-center justify-center whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-all ${focusRing} disabled:pointer-events-none disabled:opacity-50 data-[state=
|
|
45
|
+
`inline-flex items-center justify-center whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition-all ${focusRing} disabled:pointer-events-none disabled:opacity-50 data-[state=inactive]:text-muted-foreground ${ixControlSoft} ${ixSelected}`,
|
|
42
46
|
underline:
|
|
43
|
-
`inline-flex items-center justify-center whitespace-nowrap rounded-t-md pb-3 text-sm font-medium transition-all ${focusRing} disabled:pointer-events-none disabled:opacity-50 border-b-2 border-transparent data-[state=
|
|
47
|
+
`inline-flex items-center justify-center whitespace-nowrap rounded-t-md pb-3 text-sm font-medium transition-all ${focusRing} disabled:pointer-events-none disabled:opacity-50 border-b-2 border-transparent data-[state=inactive]:text-muted-foreground ${ixTabUnderline}`,
|
|
44
48
|
};
|
|
45
49
|
|
|
46
50
|
return (
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { ReasoningPart } from "../types/parts";
|
|
4
|
+
import { InlineThinkingItem } from "./inline-thinking-item";
|
|
5
|
+
|
|
6
|
+
const TEXT = "**Ensuring workspace configuration** I need to be cautious because the saved file is not verifiable.";
|
|
7
|
+
|
|
8
|
+
function part(active: boolean): ReasoningPart {
|
|
9
|
+
return { type: "reasoning", text: TEXT, time: active ? { start: Date.now() } : { start: 0, end: 4_000 } };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("InlineThinkingItem", () => {
|
|
13
|
+
it("previews the reasoning as plain text, never raw markdown marks", () => {
|
|
14
|
+
const { container } = render(<InlineThinkingItem part={part(false)} />);
|
|
15
|
+
expect(container.textContent).toContain("Ensuring workspace configuration I need to be cautious");
|
|
16
|
+
expect(container.textContent).not.toContain("**");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("opens while the model thinks, by default", () => {
|
|
20
|
+
render(<InlineThinkingItem part={part(true)} />);
|
|
21
|
+
expect(screen.getByRole("button", { expanded: true })).toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("stays closed while the model thinks when the product keeps deliberation out of view", () => {
|
|
25
|
+
render(<InlineThinkingItem part={part(true)} openWhileActive={false} />);
|
|
26
|
+
expect(screen.getByRole("button", { expanded: false })).toBeInTheDocument();
|
|
27
|
+
expect(screen.getByText("Thinking…")).toBeInTheDocument();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { memo, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { Brain } from "lucide-react";
|
|
3
|
-
import { truncateText } from "../utils/format";
|
|
3
|
+
import { plainTextFromMarkdown, truncateText } from "../utils/format";
|
|
4
4
|
import type { ReasoningPart } from "../types/parts";
|
|
5
5
|
import { Markdown } from "../markdown/markdown";
|
|
6
6
|
import { TextShimmer } from "../primitives/text-shimmer";
|
|
@@ -11,6 +11,12 @@ export interface InlineThinkingItemProps {
|
|
|
11
11
|
part: ReasoningPart;
|
|
12
12
|
defaultOpen?: boolean;
|
|
13
13
|
autoCollapse?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Open the row while the model is still thinking. A product that keeps
|
|
16
|
+
* deliberation out of the reader's way passes false: the row stays as
|
|
17
|
+
* `defaultOpen` left it, with its one-line preview, until the reader opens it.
|
|
18
|
+
*/
|
|
19
|
+
openWhileActive?: boolean;
|
|
14
20
|
className?: string;
|
|
15
21
|
contentClassName?: string;
|
|
16
22
|
}
|
|
@@ -20,6 +26,7 @@ export const InlineThinkingItem = memo(
|
|
|
20
26
|
part,
|
|
21
27
|
defaultOpen = false,
|
|
22
28
|
autoCollapse = true,
|
|
29
|
+
openWhileActive = true,
|
|
23
30
|
className,
|
|
24
31
|
contentClassName,
|
|
25
32
|
}: InlineThinkingItemProps) => {
|
|
@@ -31,12 +38,12 @@ export const InlineThinkingItem = memo(
|
|
|
31
38
|
const durationMs =
|
|
32
39
|
startTime != null && endTime != null ? endTime - startTime : undefined;
|
|
33
40
|
const isActive = startTime != null && endTime == null;
|
|
34
|
-
const preview = part.text ? truncateText(part.text, 120) : undefined;
|
|
41
|
+
const preview = part.text ? truncateText(plainTextFromMarkdown(part.text), 120) : undefined;
|
|
35
42
|
|
|
36
43
|
useEffect(() => {
|
|
37
44
|
if (isActive) {
|
|
38
45
|
autoCollapsedRef.current = false;
|
|
39
|
-
setOpen(true);
|
|
46
|
+
if (openWhileActive) setOpen(true);
|
|
40
47
|
return;
|
|
41
48
|
}
|
|
42
49
|
|
|
@@ -48,7 +55,7 @@ export const InlineThinkingItem = memo(
|
|
|
48
55
|
|
|
49
56
|
return () => window.clearTimeout(timer);
|
|
50
57
|
}
|
|
51
|
-
}, [autoCollapse, durationMs, isActive]);
|
|
58
|
+
}, [autoCollapse, durationMs, isActive, openWhileActive]);
|
|
52
59
|
|
|
53
60
|
return (
|
|
54
61
|
<RunRowShell
|
package/src/utils/format.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { formatBytes, formatUptime } from "./format";
|
|
2
|
+
import { formatBytes, formatUptime, plainTextFromMarkdown } from "./format";
|
|
3
3
|
|
|
4
4
|
describe("formatUptime", () => {
|
|
5
5
|
it("renders seconds for short durations", () => {
|
|
@@ -41,3 +41,18 @@ describe("formatBytes", () => {
|
|
|
41
41
|
expect(formatBytes(-1)).toBe("—");
|
|
42
42
|
});
|
|
43
43
|
});
|
|
44
|
+
|
|
45
|
+
describe("plainTextFromMarkdown", () => {
|
|
46
|
+
it("drops emphasis, headings, list markers, code marks and link syntax", () => {
|
|
47
|
+
expect(plainTextFromMarkdown("**Ensuring workspace configuration** I need to check")).toBe("Ensuring workspace configuration I need to check");
|
|
48
|
+
expect(plainTextFromMarkdown("## Plan\n- item one\n- *soft* point")).toBe("Plan\nitem one\nsoft point");
|
|
49
|
+
expect(plainTextFromMarkdown("see [docs](https://example.com) and _this_")).toBe("see docs and this");
|
|
50
|
+
expect(plainTextFromMarkdown("**bold** and __also__ ~~gone~~")).toBe("bold and also gone");
|
|
51
|
+
expect(plainTextFromMarkdown("1. First\n2) Second")).toBe("First\nSecond");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("keeps characters that are not markdown marks", () => {
|
|
55
|
+
expect(plainTextFromMarkdown("read `src/a_b.ts` and snake_case_name")).toBe("read src/a_b.ts and snake_case_name");
|
|
56
|
+
expect(plainTextFromMarkdown("2 * 3 * 4 = 24")).toBe("2 * 3 * 4 = 24");
|
|
57
|
+
});
|
|
58
|
+
});
|
package/src/utils/format.ts
CHANGED
|
@@ -8,6 +8,23 @@ export function formatDuration(ms: number): string {
|
|
|
8
8
|
return remaining > 0 ? `${minutes}m ${remaining}s` : `${minutes}m`;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Markdown read as plain text, for a one-line preview: code fences, emphasis,
|
|
13
|
+
* headings, list and quote markers, and link syntax are removed, so a preview
|
|
14
|
+
* never shows `**` or `#`. Underscores inside words (`snake_case`) are kept.
|
|
15
|
+
*/
|
|
16
|
+
export function plainTextFromMarkdown(text: string): string {
|
|
17
|
+
return text
|
|
18
|
+
.replace(/```[^\n]*\n?([\s\S]*?)```/g, "$1")
|
|
19
|
+
.replace(/`([^`]*)`/g, "$1")
|
|
20
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
21
|
+
.replace(/^[ \t]{0,3}(?:#{1,6}[ \t]+|>[ \t]?|[-*+][ \t]+|\d+[.)][ \t]+)/gm, "")
|
|
22
|
+
.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "$2")
|
|
23
|
+
.replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, "$1$2")
|
|
24
|
+
.replace(/(^|[^\w])_(?=\S)([^_\n]*?\S)_(?!\w)/g, "$1$2")
|
|
25
|
+
.replace(/~~(?=\S)([\s\S]*?\S)~~/g, "$1");
|
|
26
|
+
}
|
|
27
|
+
|
|
11
28
|
/** Truncate text to `max` characters, appending "..." if truncated. */
|
|
12
29
|
export function truncateText(text: string, max: number): string {
|
|
13
30
|
const cleaned = text.replace(/\s+/g, " ").trim();
|
package/src/utils/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { copyText } from './copy-text';
|
|
2
|
-
export { formatBytes, formatDuration, formatUptime, truncateText } from './format';
|
|
2
|
+
export { formatBytes, formatDuration, formatUptime, plainTextFromMarkdown, truncateText } from './format';
|
|
3
3
|
export { timeAgo } from './time-ago';
|
|
4
4
|
export {
|
|
5
5
|
getToolCategory,
|
|
@@ -15,3 +15,12 @@ export {
|
|
|
15
15
|
focusRing,
|
|
16
16
|
focusRingInset,
|
|
17
17
|
} from '../lib/focus';
|
|
18
|
+
export {
|
|
19
|
+
ixControl,
|
|
20
|
+
ixControlAfter,
|
|
21
|
+
ixControlSoft,
|
|
22
|
+
ixField,
|
|
23
|
+
ixRow,
|
|
24
|
+
ixSelected,
|
|
25
|
+
ixTabUnderline,
|
|
26
|
+
} from '../lib/interaction';
|