@tangle-network/ui 11.16.10 → 11.17.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 +18 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/primitives/badge.d.ts +7 -0
- package/dist/primitives/badge.js +3 -3
- package/dist/primitives/chip.d.ts +38 -0
- package/dist/primitives/chip.js +41 -0
- package/dist/primitives/icon-tile.d.ts +32 -0
- package/dist/primitives/icon-tile.js +49 -0
- package/dist/primitives/tag.d.ts +36 -0
- package/dist/primitives/tag.js +42 -0
- package/dist/primitives/tone.d.ts +40 -0
- package/dist/primitives/tone.js +106 -0
- package/dist/primitives.d.ts +5 -1
- package/dist/primitives.js +5 -1
- package/dist/tool-previews/command-preview.js +56 -19
- package/package.json +2 -2
- package/src/primitives/badge.tsx +17 -4
- package/src/primitives/chip.test.tsx +178 -0
- package/src/primitives/chip.tsx +108 -0
- package/src/primitives/icon-tile.stories.tsx +74 -0
- package/src/primitives/icon-tile.tsx +96 -0
- package/src/primitives/index.ts +12 -0
- package/src/primitives/tag-chip.stories.tsx +226 -0
- package/src/primitives/tag.tsx +94 -0
- package/src/primitives/tone.ts +130 -0
- package/src/tool-previews/command-preview.test.tsx +18 -0
- package/src/tool-previews/command-preview.tsx +75 -34
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { focusRing } from "../lib/focus.js";
|
|
2
2
|
import { cn } from "../lib/utils.js";
|
|
3
|
-
import { memo, useId, useState } from "react";
|
|
3
|
+
import { memo, useId, useLayoutEffect, useRef, useState } from "react";
|
|
4
4
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
5
5
|
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
|
|
6
6
|
//#region src/tool-previews/command-preview.tsx
|
|
@@ -146,6 +146,49 @@ function CommandText({ command }) {
|
|
|
146
146
|
}, i) : token) });
|
|
147
147
|
}
|
|
148
148
|
/**
|
|
149
|
+
* One output stream, capped at a readable height. When the cap hides lines,
|
|
150
|
+
* a control says so and shows the rest in place: a region that ends flush
|
|
151
|
+
* with its frame gave no sign that more output was there.
|
|
152
|
+
*/
|
|
153
|
+
function OutputRegion({ label, text, className }) {
|
|
154
|
+
const ref = useRef(null);
|
|
155
|
+
const [clipped, setClipped] = useState(false);
|
|
156
|
+
const [full, setFull] = useState(false);
|
|
157
|
+
useLayoutEffect(() => {
|
|
158
|
+
const el = ref.current;
|
|
159
|
+
if (!el || full) return;
|
|
160
|
+
const check = () => setClipped(el.scrollHeight > el.clientHeight + 1);
|
|
161
|
+
check();
|
|
162
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
163
|
+
const observer = new ResizeObserver(check);
|
|
164
|
+
observer.observe(el);
|
|
165
|
+
return () => observer.disconnect();
|
|
166
|
+
}, [full]);
|
|
167
|
+
const lines = text.replace(/\n$/, "").split("\n").length;
|
|
168
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
169
|
+
className,
|
|
170
|
+
children: [/* @__PURE__ */ jsx("pre", {
|
|
171
|
+
ref,
|
|
172
|
+
tabIndex: 0,
|
|
173
|
+
role: "region",
|
|
174
|
+
"aria-label": label,
|
|
175
|
+
className: cn("overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] px-3 py-2 leading-relaxed", full ? "" : "max-h-80", focusRing),
|
|
176
|
+
children: text
|
|
177
|
+
}), clipped && !full ? /* @__PURE__ */ jsxs("button", {
|
|
178
|
+
type: "button",
|
|
179
|
+
onClick: () => setFull(true),
|
|
180
|
+
className: cn("w-full border-t border-white/10 px-3 py-1.5 text-left font-sans text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground", focusRing),
|
|
181
|
+
children: [
|
|
182
|
+
"Show all ",
|
|
183
|
+
label,
|
|
184
|
+
" (",
|
|
185
|
+
lines,
|
|
186
|
+
" lines)"
|
|
187
|
+
]
|
|
188
|
+
}) : null]
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
149
192
|
* One shell command as a terminal block: a `$ command` prompt line with its
|
|
150
193
|
* exit status, and stdout and stderr in separate regions below it.
|
|
151
194
|
*
|
|
@@ -208,26 +251,20 @@ const CommandPreview = memo(({ part, defaultExpanded = false, className }) => {
|
|
|
208
251
|
id: bodyId,
|
|
209
252
|
className: "border-t border-white/10",
|
|
210
253
|
children: [
|
|
211
|
-
output.stdout ? /* @__PURE__ */ jsx(
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
className: cn("max-h-80 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] px-3 py-2 leading-relaxed", focusRing),
|
|
216
|
-
children: output.stdout
|
|
254
|
+
output.stdout ? /* @__PURE__ */ jsx(OutputRegion, {
|
|
255
|
+
label: "stdout",
|
|
256
|
+
text: output.stdout,
|
|
257
|
+
className: ""
|
|
217
258
|
}) : null,
|
|
218
|
-
output.stderr ? /* @__PURE__ */ jsx(
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
"
|
|
222
|
-
className: cn("max-h-80 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] border-t border-white/10 px-3 py-2 leading-relaxed text-[var(--surface-danger-text)]", focusRing),
|
|
223
|
-
children: output.stderr
|
|
259
|
+
output.stderr ? /* @__PURE__ */ jsx(OutputRegion, {
|
|
260
|
+
label: "stderr",
|
|
261
|
+
text: output.stderr,
|
|
262
|
+
className: "border-t border-white/10 text-[var(--surface-danger-text)]"
|
|
224
263
|
}) : null,
|
|
225
|
-
errorText ? /* @__PURE__ */ jsx(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
"
|
|
229
|
-
className: cn("max-h-80 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] border-t border-white/10 px-3 py-2 leading-relaxed text-[var(--surface-danger-text)]", focusRing),
|
|
230
|
-
children: errorText
|
|
264
|
+
errorText ? /* @__PURE__ */ jsx(OutputRegion, {
|
|
265
|
+
label: "error",
|
|
266
|
+
text: errorText,
|
|
267
|
+
className: "border-t border-white/10 text-[var(--surface-danger-text)]"
|
|
231
268
|
}) : null
|
|
232
269
|
]
|
|
233
270
|
}) : null]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/ui",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.17.0",
|
|
4
4
|
"description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -142,7 +142,7 @@
|
|
|
142
142
|
"peerDependencies": {
|
|
143
143
|
"@hocuspocus/provider": ">=2.15.0 <5.0.0",
|
|
144
144
|
"@nanostores/react": ">=0.8.4 <3.0.0",
|
|
145
|
-
"@tangle-network/brand": "^1.
|
|
145
|
+
"@tangle-network/brand": "^1.11.0",
|
|
146
146
|
"@tiptap/core": ">=3.20.4 <4.0.0",
|
|
147
147
|
"@tiptap/extension-collaboration": ">=3.20.4 <4.0.0",
|
|
148
148
|
"@tiptap/extension-collaboration-caret": ">=3.7.0 <4.0.0",
|
package/src/primitives/badge.tsx
CHANGED
|
@@ -22,15 +22,21 @@ const badgeVariants = cva(
|
|
|
22
22
|
"border-[var(--surface-info-border)] bg-[var(--surface-info-bg)] text-[var(--surface-info-text)]",
|
|
23
23
|
sandbox:
|
|
24
24
|
"border-border bg-[var(--accent-surface-soft)] text-[var(--accent-text)]",
|
|
25
|
-
/*
|
|
25
|
+
/* Sandbox lifecycle variants: compatibility only, painted with the
|
|
26
|
+
* status triples. A lifecycle state is domain meaning, so its mapping
|
|
27
|
+
* lives in sandbox-ui's lifecycle adapter over `StatusPill`; these
|
|
28
|
+
* stay until consumers move. Running is up and healthy (success);
|
|
29
|
+
* creating is still in progress (info, as StatusPill's `running`);
|
|
30
|
+
* stopped and warm can be resumed (warning); cold is archived (info);
|
|
31
|
+
* deleted is gone (neutral). */
|
|
26
32
|
running:
|
|
27
|
-
"border-[var(--surface-
|
|
33
|
+
"border-[var(--surface-success-border)] bg-[var(--surface-success-bg)] text-[var(--surface-success-text)]",
|
|
28
34
|
creating:
|
|
29
|
-
"border-[var(--surface-
|
|
35
|
+
"border-[var(--surface-info-border)] bg-[var(--surface-info-bg)] text-[var(--surface-info-text)]",
|
|
30
36
|
stopped:
|
|
31
37
|
"border-[var(--surface-warning-border)] bg-[var(--surface-warning-bg)] text-[var(--surface-warning-text)]",
|
|
32
38
|
warm:
|
|
33
|
-
"border-[var(--surface-
|
|
39
|
+
"border-[var(--surface-warning-border)] bg-[var(--surface-warning-bg)] text-[var(--surface-warning-text)]",
|
|
34
40
|
cold:
|
|
35
41
|
"border-[var(--surface-info-border)] bg-[var(--surface-info-bg)] text-[var(--surface-info-text)]",
|
|
36
42
|
deleted:
|
|
@@ -43,6 +49,13 @@ const badgeVariants = cva(
|
|
|
43
49
|
},
|
|
44
50
|
);
|
|
45
51
|
|
|
52
|
+
/**
|
|
53
|
+
* `success`, `warning`, `error` and `info` paint the same triples as
|
|
54
|
+
* `StatusPill`; for a status, prefer `StatusPill`, which adds the glyph that
|
|
55
|
+
* survives greyscale. The lifecycle variants (`running`, `creating`,
|
|
56
|
+
* `stopped`, `warm`, `cold`, `deleted`) are deprecated: use sandbox-ui's
|
|
57
|
+
* lifecycle adapter.
|
|
58
|
+
*/
|
|
46
59
|
export type BadgeVariant =
|
|
47
60
|
| "default"
|
|
48
61
|
| "secondary"
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { Chip } from "./chip";
|
|
6
|
+
import { IconTile, initialsOf } from "./icon-tile";
|
|
7
|
+
import { Tag } from "./tag";
|
|
8
|
+
import { CATEGORY_TONES, TONE_CLASSES, toneFor } from "./tone";
|
|
9
|
+
|
|
10
|
+
function FilterRow() {
|
|
11
|
+
const [on, setOn] = React.useState<Record<string, boolean>>({ Drafts: true, Scheduled: false });
|
|
12
|
+
return (
|
|
13
|
+
<div>
|
|
14
|
+
{Object.keys(on).map((name) => (
|
|
15
|
+
<Chip
|
|
16
|
+
key={name}
|
|
17
|
+
selected={on[name]}
|
|
18
|
+
onSelectedChange={(next) => setOn((prev) => ({ ...prev, [name]: next }))}
|
|
19
|
+
>
|
|
20
|
+
{name}
|
|
21
|
+
</Chip>
|
|
22
|
+
))}
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("Chip", () => {
|
|
28
|
+
it("is a native button that runs onClick when it is an action chip", async () => {
|
|
29
|
+
const onClick = vi.fn();
|
|
30
|
+
render(<Chip onClick={onClick}>Draft a launch post</Chip>);
|
|
31
|
+
const chip = screen.getByRole("button", { name: "Draft a launch post" });
|
|
32
|
+
expect(chip).toHaveAttribute("type", "button");
|
|
33
|
+
expect(chip).not.toHaveAttribute("aria-pressed");
|
|
34
|
+
await userEvent.click(chip);
|
|
35
|
+
expect(onClick).toHaveBeenCalledOnce();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("toggles from the keyboard with Space and Enter and reports aria-pressed", async () => {
|
|
39
|
+
const user = userEvent.setup();
|
|
40
|
+
render(<FilterRow />);
|
|
41
|
+
const drafts = screen.getByRole("button", { name: "Drafts" });
|
|
42
|
+
const scheduled = screen.getByRole("button", { name: "Scheduled" });
|
|
43
|
+
expect(drafts).toHaveAttribute("aria-pressed", "true");
|
|
44
|
+
expect(scheduled).toHaveAttribute("aria-pressed", "false");
|
|
45
|
+
|
|
46
|
+
await user.tab();
|
|
47
|
+
expect(drafts).toHaveFocus();
|
|
48
|
+
await user.keyboard(" ");
|
|
49
|
+
expect(drafts).toHaveAttribute("aria-pressed", "false");
|
|
50
|
+
|
|
51
|
+
await user.tab();
|
|
52
|
+
expect(scheduled).toHaveFocus();
|
|
53
|
+
await user.keyboard("{Enter}");
|
|
54
|
+
expect(scheduled).toHaveAttribute("aria-pressed", "true");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Selection must survive greyscale: the check replaces the icon only when on.
|
|
58
|
+
it("shows a check glyph only while selected", () => {
|
|
59
|
+
const { rerender } = render(
|
|
60
|
+
<Chip selected={false} icon={<svg data-testid="icon" />}>
|
|
61
|
+
Video
|
|
62
|
+
</Chip>,
|
|
63
|
+
);
|
|
64
|
+
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
|
65
|
+
expect(document.querySelector(".lucide-check")).toBeNull();
|
|
66
|
+
rerender(
|
|
67
|
+
<Chip selected icon={<svg data-testid="icon" />}>
|
|
68
|
+
Video
|
|
69
|
+
</Chip>,
|
|
70
|
+
);
|
|
71
|
+
expect(screen.queryByTestId("icon")).toBeNull();
|
|
72
|
+
expect(document.querySelector(".lucide-check")).not.toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("does not toggle while disabled", () => {
|
|
76
|
+
const onSelectedChange = vi.fn();
|
|
77
|
+
render(
|
|
78
|
+
<Chip selected={false} disabled onSelectedChange={onSelectedChange}>
|
|
79
|
+
Archived
|
|
80
|
+
</Chip>,
|
|
81
|
+
);
|
|
82
|
+
fireEvent.click(screen.getByRole("button", { name: "Archived" }));
|
|
83
|
+
expect(onSelectedChange).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("lets a consumer onClick veto the toggle with preventDefault", () => {
|
|
87
|
+
const onSelectedChange = vi.fn();
|
|
88
|
+
render(
|
|
89
|
+
<Chip selected={false} onClick={(e) => e.preventDefault()} onSelectedChange={onSelectedChange}>
|
|
90
|
+
Locked
|
|
91
|
+
</Chip>,
|
|
92
|
+
);
|
|
93
|
+
fireEvent.click(screen.getByRole("button", { name: "Locked" }));
|
|
94
|
+
expect(onSelectedChange).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("Tag", () => {
|
|
99
|
+
it("is a static label with no tab stop of its own", async () => {
|
|
100
|
+
render(<Tag tone="teal">Research</Tag>);
|
|
101
|
+
expect(screen.getByText("Research")).toBeInTheDocument();
|
|
102
|
+
expect(screen.queryByRole("button")).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("removes from the keyboard through a named remove button", async () => {
|
|
106
|
+
const user = userEvent.setup();
|
|
107
|
+
const onRemove = vi.fn();
|
|
108
|
+
render(<Tag onRemove={onRemove}>ada@example.com</Tag>);
|
|
109
|
+
await user.tab();
|
|
110
|
+
const remove = screen.getByRole("button", { name: "Remove ada@example.com" });
|
|
111
|
+
expect(remove).toHaveFocus();
|
|
112
|
+
await user.keyboard("{Enter}");
|
|
113
|
+
await user.keyboard(" ");
|
|
114
|
+
expect(onRemove).toHaveBeenCalledTimes(2);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("takes an explicit remove label when the label is not plain text", () => {
|
|
118
|
+
render(
|
|
119
|
+
<Tag onRemove={() => {}} removeLabel="Remove the LinkedIn channel">
|
|
120
|
+
<strong>LinkedIn</strong>
|
|
121
|
+
</Tag>,
|
|
122
|
+
);
|
|
123
|
+
expect(screen.getByRole("button", { name: "Remove the LinkedIn channel" })).toBeInTheDocument();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("IconTile", () => {
|
|
128
|
+
it("falls back from a broken image to the glyph, then to initials", () => {
|
|
129
|
+
const { container, rerender } = render(<IconTile src="https://example.invalid/logo.png" name="Acme Robotics" />);
|
|
130
|
+
const img = container.querySelector("img");
|
|
131
|
+
expect(img).not.toBeNull();
|
|
132
|
+
fireEvent.error(img as HTMLImageElement);
|
|
133
|
+
expect(container.querySelector("img")).toBeNull();
|
|
134
|
+
expect(container.textContent).toBe("AR");
|
|
135
|
+
|
|
136
|
+
rerender(<IconTile name="Acme Robotics" icon={<svg data-testid="glyph" />} />);
|
|
137
|
+
expect(screen.getByTestId("glyph")).toBeInTheDocument();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("is decorative unless it is given a label", () => {
|
|
141
|
+
const { container, rerender } = render(<IconTile name="Acme" />);
|
|
142
|
+
expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true");
|
|
143
|
+
rerender(<IconTile name="Acme" label="Acme workspace" />);
|
|
144
|
+
expect(screen.getByRole("img", { name: "Acme workspace" })).toBeInTheDocument();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it.each([
|
|
148
|
+
["Acme Robotics", "AR"],
|
|
149
|
+
["acme", "AC"],
|
|
150
|
+
["jane.doe", "JD"],
|
|
151
|
+
[" ", ""],
|
|
152
|
+
["李 小龍", "李小"],
|
|
153
|
+
["🦊 fox", "🦊F"],
|
|
154
|
+
])("derives the initials of %j as %j", (name, initials) => {
|
|
155
|
+
expect(initialsOf(name)).toBe(initials);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("toneFor", () => {
|
|
160
|
+
it("gives a key the same categorical tone every time", () => {
|
|
161
|
+
expect(toneFor("linkedin")).toBe(toneFor("linkedin"));
|
|
162
|
+
expect(CATEGORY_TONES).toContain(toneFor("linkedin"));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("spreads keys across the categorical set", () => {
|
|
166
|
+
const used = new Set(Array.from({ length: 64 }, (_, i) => toneFor(`project-${i}`)));
|
|
167
|
+
expect(used.size).toBe(CATEGORY_TONES.length);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// The sandbox-ui style bundle and Tailwind read class names statically, so
|
|
171
|
+
// every tone's classes must name their own tone's tokens literally.
|
|
172
|
+
it.each(CATEGORY_TONES)("writes %s's classes against its own tokens", (tone) => {
|
|
173
|
+
for (const value of Object.values(TONE_CLASSES[tone])) {
|
|
174
|
+
expect(value).toContain(`--tone-${tone}-`);
|
|
175
|
+
expect(value).not.toMatch(new RegExp(`--tone-(?!${tone}-)`));
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Check } from "lucide-react";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { focusRing } from "../lib/focus";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { TONE_CLASSES, type Tone } from "./tone";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A label the user presses: a native `<button>` shaped like a `Tag`.
|
|
9
|
+
*
|
|
10
|
+
* Two kinds, chosen by whether `selected` is passed:
|
|
11
|
+
*
|
|
12
|
+
* - **Action** (`selected` omitted): runs `onClick` — a suggested prompt, an
|
|
13
|
+
* "add filter" entry point.
|
|
14
|
+
* - **Filter / toggle** (`selected` is a boolean): a toggle button with
|
|
15
|
+
* `aria-pressed`. Pressing it calls `onSelectedChange(!selected)`; the caller
|
|
16
|
+
* owns the state. Use it for a set of filters (each independently on or off)
|
|
17
|
+
* or a single on/off option such as "Plan first".
|
|
18
|
+
*
|
|
19
|
+
* Selection is never shown by colour alone: a selected chip gains a check in
|
|
20
|
+
* place of its icon, a stronger border and a deeper fill, and assistive tech
|
|
21
|
+
* reads it from `aria-pressed`.
|
|
22
|
+
*
|
|
23
|
+
* For a removable label, use `Tag` with `onRemove`; a button cannot contain a
|
|
24
|
+
* second button.
|
|
25
|
+
*/
|
|
26
|
+
export interface ChipProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
|
27
|
+
children: React.ReactNode;
|
|
28
|
+
/** Categorical tone, or `neutral` (the default). */
|
|
29
|
+
tone?: Tone;
|
|
30
|
+
/** `soft` fills the chip; `outline` keeps only the border until selected. */
|
|
31
|
+
emphasis?: "soft" | "outline";
|
|
32
|
+
size?: "sm" | "md";
|
|
33
|
+
/** Leading glyph, drawn in the tone's icon colour. A selected chip shows a check instead. */
|
|
34
|
+
icon?: React.ReactNode;
|
|
35
|
+
/** Makes the chip a toggle button; omit for an action chip. */
|
|
36
|
+
selected?: boolean;
|
|
37
|
+
onSelectedChange?: (selected: boolean) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SIZE = {
|
|
41
|
+
sm: "h-6 gap-1 px-2 text-xs [&_svg]:size-3",
|
|
42
|
+
md: "h-8 gap-1.5 px-3 text-sm [&_svg]:size-3.5",
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
const Chip = React.forwardRef<HTMLButtonElement, ChipProps>(
|
|
46
|
+
(
|
|
47
|
+
{
|
|
48
|
+
className,
|
|
49
|
+
children,
|
|
50
|
+
tone = "neutral",
|
|
51
|
+
emphasis = "soft",
|
|
52
|
+
size = "sm",
|
|
53
|
+
icon,
|
|
54
|
+
selected,
|
|
55
|
+
onSelectedChange,
|
|
56
|
+
onClick,
|
|
57
|
+
type = "button",
|
|
58
|
+
disabled,
|
|
59
|
+
...props
|
|
60
|
+
},
|
|
61
|
+
ref,
|
|
62
|
+
) => {
|
|
63
|
+
const classes = TONE_CLASSES[tone];
|
|
64
|
+
const toggle = selected !== undefined;
|
|
65
|
+
const on = selected === true;
|
|
66
|
+
const glyph = on ? <Check /> : icon;
|
|
67
|
+
return (
|
|
68
|
+
<button
|
|
69
|
+
ref={ref}
|
|
70
|
+
type={type}
|
|
71
|
+
disabled={disabled}
|
|
72
|
+
aria-pressed={toggle ? on : undefined}
|
|
73
|
+
onClick={(event) => {
|
|
74
|
+
onClick?.(event);
|
|
75
|
+
if (toggle && !event.defaultPrevented) onSelectedChange?.(!on);
|
|
76
|
+
}}
|
|
77
|
+
className={cn(
|
|
78
|
+
// The pseudo-element widens a small chip's hit area on touch screens
|
|
79
|
+
// without changing its drawn size or the row's layout.
|
|
80
|
+
"relative inline-flex min-w-0 max-w-full shrink-0 cursor-pointer select-none items-center rounded-md border font-medium transition-[background-color,border-color,color] duration-150 ease-out before:absolute before:inset-x-0 before:-inset-y-1 before:content-[''] pointer-coarse:before:-inset-y-2.5",
|
|
81
|
+
SIZE[size],
|
|
82
|
+
emphasis === "outline" && !on ? classes.outline : classes.surface,
|
|
83
|
+
// A selected chip keeps its fill under the pointer; the rest-state
|
|
84
|
+
// hover would read as half-deselecting it.
|
|
85
|
+
!disabled && !on && classes.hover,
|
|
86
|
+
on && classes.selected,
|
|
87
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
88
|
+
focusRing,
|
|
89
|
+
className,
|
|
90
|
+
)}
|
|
91
|
+
{...props}
|
|
92
|
+
>
|
|
93
|
+
{glyph ? (
|
|
94
|
+
<span aria-hidden="true" className={cn("inline-flex shrink-0", classes.icon)}>
|
|
95
|
+
{glyph}
|
|
96
|
+
</span>
|
|
97
|
+
) : null}
|
|
98
|
+
{/* The full text on hover, since a long label truncates. */}
|
|
99
|
+
<span className="min-w-0 truncate" title={typeof children === "string" ? children : undefined}>
|
|
100
|
+
{children}
|
|
101
|
+
</span>
|
|
102
|
+
</button>
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
Chip.displayName = "Chip";
|
|
107
|
+
|
|
108
|
+
export { Chip };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Folder, Globe } from "lucide-react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { IconTile } from "./icon-tile";
|
|
5
|
+
|
|
6
|
+
function BothThemes({ children }: { children: ReactNode }) {
|
|
7
|
+
return (
|
|
8
|
+
<div className="grid gap-4 p-4 lg:grid-cols-2">
|
|
9
|
+
{(["dark", "light"] as const).map((theme) => (
|
|
10
|
+
<div
|
|
11
|
+
key={theme}
|
|
12
|
+
data-theme={theme}
|
|
13
|
+
className="min-w-0 rounded-xl border border-[var(--border-subtle)] bg-[var(--bg-root)] p-4 text-[var(--text-primary)]"
|
|
14
|
+
>
|
|
15
|
+
{children}
|
|
16
|
+
</div>
|
|
17
|
+
))}
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A 1x1 transparent PNG stands in for a loaded logo; the second URL never
|
|
23
|
+
// resolves, so that row shows the fallback a broken favicon gets.
|
|
24
|
+
const LOGO = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
|
25
|
+
|
|
26
|
+
const PROJECTS: { name: string; src?: string; icon?: ReactNode; note: string }[] = [
|
|
27
|
+
{ name: "Acme Robotics", src: LOGO, note: "Image" },
|
|
28
|
+
{ name: "Northwind Traders", src: "https://logo.invalid/northwind.png", note: "Broken image → initials" },
|
|
29
|
+
{ name: "Website", icon: <Globe />, note: "Glyph" },
|
|
30
|
+
{ name: "Shared drive", icon: <Folder />, note: "Glyph" },
|
|
31
|
+
{ name: "Quarterly enterprise procurement working group for the EMEA region", note: "Long name → initials" },
|
|
32
|
+
{ name: "x", note: "One letter" },
|
|
33
|
+
{ name: "李 小龍", note: "Non-Latin" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const meta: Meta<typeof IconTile> = {
|
|
37
|
+
title: "Primitives/IconTile",
|
|
38
|
+
component: IconTile,
|
|
39
|
+
parameters: { layout: "fullscreen" },
|
|
40
|
+
};
|
|
41
|
+
export default meta;
|
|
42
|
+
type Story = StoryObj<typeof IconTile>;
|
|
43
|
+
|
|
44
|
+
/** A project list: the tone comes from the name, so a project keeps its colour everywhere. */
|
|
45
|
+
export const ProjectList: Story = {
|
|
46
|
+
render: () => (
|
|
47
|
+
<BothThemes>
|
|
48
|
+
<ul className="flex flex-col gap-2">
|
|
49
|
+
{PROJECTS.map((p) => (
|
|
50
|
+
<li key={p.name} className="flex min-w-0 items-center gap-3">
|
|
51
|
+
<IconTile name={p.name} src={p.src} icon={p.icon} />
|
|
52
|
+
<span className="min-w-0 flex-1 truncate text-sm font-medium">{p.name}</span>
|
|
53
|
+
<span className="shrink-0 text-xs text-[var(--text-muted)]">{p.note}</span>
|
|
54
|
+
</li>
|
|
55
|
+
))}
|
|
56
|
+
</ul>
|
|
57
|
+
</BothThemes>
|
|
58
|
+
),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const Sizes: Story = {
|
|
62
|
+
render: () => (
|
|
63
|
+
<BothThemes>
|
|
64
|
+
<div className="flex items-end gap-3">
|
|
65
|
+
{(["xs", "sm", "md", "lg"] as const).map((size) => (
|
|
66
|
+
<IconTile key={size} size={size} name="Tangle Network" />
|
|
67
|
+
))}
|
|
68
|
+
{(["xs", "sm", "md", "lg"] as const).map((size) => (
|
|
69
|
+
<IconTile key={`g-${size}`} size={size} tone="cyan" icon={<Globe />} />
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
</BothThemes>
|
|
73
|
+
),
|
|
74
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { type CategoryTone, TONE_CLASSES, toneFor } from "./tone";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A square identity mark for a row or card: a project, a channel, an account,
|
|
7
|
+
* a file kind. It shows the first of these that is available:
|
|
8
|
+
*
|
|
9
|
+
* 1. `src` — an image (a logo or favicon). If it fails to load, the tile falls
|
|
10
|
+
* through to the next option instead of showing a broken image.
|
|
11
|
+
* 2. `icon` — a glyph, drawn in the tone's icon colour.
|
|
12
|
+
* 3. `name` — up to two initials.
|
|
13
|
+
*
|
|
14
|
+
* The tone is categorical. Omit it to derive a stable one from `name`, so the
|
|
15
|
+
* same project gets the same colour on every screen. A tile is decorative
|
|
16
|
+
* unless `label` is given: next to a visible name it would only repeat it.
|
|
17
|
+
*/
|
|
18
|
+
export interface IconTileProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
|
|
19
|
+
/** Defaults to a tone derived from `name`, or violet when there is no name. */
|
|
20
|
+
tone?: CategoryTone;
|
|
21
|
+
size?: "xs" | "sm" | "md" | "lg";
|
|
22
|
+
src?: string;
|
|
23
|
+
icon?: React.ReactNode;
|
|
24
|
+
/** The thing's name. Supplies the initials and the derived tone. */
|
|
25
|
+
name?: string;
|
|
26
|
+
/** Accessible name. Give it only when no visible label names the thing. */
|
|
27
|
+
label?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SIZE = {
|
|
31
|
+
xs: "size-5 rounded-[5px] text-[10px] [&_svg]:size-3",
|
|
32
|
+
sm: "size-6 rounded-md text-[11px] [&_svg]:size-3.5",
|
|
33
|
+
md: "size-8 rounded-md text-xs [&_svg]:size-4",
|
|
34
|
+
lg: "size-10 rounded-lg text-sm [&_svg]:size-5",
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
/** Up to two initials: the first letters of the first two words, or of the one word. */
|
|
38
|
+
export function initialsOf(name: string): string {
|
|
39
|
+
const words = name.trim().split(/[\s._-]+/u).filter(Boolean);
|
|
40
|
+
if (words.length === 0) return "";
|
|
41
|
+
// Array.from splits by code point, so an emoji or astral letter stays whole.
|
|
42
|
+
const first = (word: string) => Array.from(word)[0] ?? "";
|
|
43
|
+
const letters = words.length === 1 ? Array.from(words[0]).slice(0, 2).join("") : first(words[0]) + first(words[1]);
|
|
44
|
+
return letters.toLocaleUpperCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const IconTile = React.forwardRef<HTMLSpanElement, IconTileProps>(
|
|
48
|
+
({ className, tone, size = "md", src, icon, name, label, ...props }, ref) => {
|
|
49
|
+
const [failedSrc, setFailedSrc] = React.useState<string | null>(null);
|
|
50
|
+
const imgRef = React.useRef<HTMLImageElement>(null);
|
|
51
|
+
// A server-rendered image that failed before hydration never fires onError
|
|
52
|
+
// again, so check it once mounted.
|
|
53
|
+
React.useEffect(() => {
|
|
54
|
+
const img = imgRef.current;
|
|
55
|
+
if (img?.complete && img.naturalWidth === 0 && src) setFailedSrc(src);
|
|
56
|
+
}, [src]);
|
|
57
|
+
const resolvedTone = tone ?? (name ? toneFor(name) : "violet");
|
|
58
|
+
const classes = TONE_CLASSES[resolvedTone];
|
|
59
|
+
const showImage = Boolean(src) && failedSrc !== src;
|
|
60
|
+
const initials = name ? initialsOf(name) : "";
|
|
61
|
+
return (
|
|
62
|
+
<span
|
|
63
|
+
ref={ref}
|
|
64
|
+
role={label ? "img" : undefined}
|
|
65
|
+
aria-label={label}
|
|
66
|
+
aria-hidden={label ? undefined : true}
|
|
67
|
+
className={cn(
|
|
68
|
+
"inline-flex shrink-0 select-none items-center justify-center overflow-hidden border font-semibold",
|
|
69
|
+
SIZE[size],
|
|
70
|
+
showImage ? "border-[var(--border-subtle)] bg-[var(--surface-neutral-bg)]" : classes.surface,
|
|
71
|
+
!showImage && icon ? classes.icon : null,
|
|
72
|
+
className,
|
|
73
|
+
)}
|
|
74
|
+
{...props}
|
|
75
|
+
>
|
|
76
|
+
{showImage ? (
|
|
77
|
+
<img
|
|
78
|
+
ref={imgRef}
|
|
79
|
+
src={src}
|
|
80
|
+
alt=""
|
|
81
|
+
className="size-full object-cover"
|
|
82
|
+
onError={() => setFailedSrc(src ?? null)}
|
|
83
|
+
draggable={false}
|
|
84
|
+
/>
|
|
85
|
+
) : icon ? (
|
|
86
|
+
icon
|
|
87
|
+
) : (
|
|
88
|
+
initials
|
|
89
|
+
)}
|
|
90
|
+
</span>
|
|
91
|
+
);
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
IconTile.displayName = "IconTile";
|
|
95
|
+
|
|
96
|
+
export { IconTile };
|
package/src/primitives/index.ts
CHANGED
|
@@ -136,6 +136,18 @@ export type { PageHeaderProps } from "./page-header";
|
|
|
136
136
|
export { StatusPill } from "./status-pill";
|
|
137
137
|
export type { StatusPillProps, StatusTone } from "./status-pill";
|
|
138
138
|
|
|
139
|
+
export { Tag } from "./tag";
|
|
140
|
+
export type { TagProps } from "./tag";
|
|
141
|
+
|
|
142
|
+
export { Chip } from "./chip";
|
|
143
|
+
export type { ChipProps } from "./chip";
|
|
144
|
+
|
|
145
|
+
export { IconTile, initialsOf } from "./icon-tile";
|
|
146
|
+
export type { IconTileProps } from "./icon-tile";
|
|
147
|
+
|
|
148
|
+
export { CATEGORY_TONES, TONE_CLASSES, toneFor } from "./tone";
|
|
149
|
+
export type { CategoryTone, Tone, ToneClasses } from "./tone";
|
|
150
|
+
|
|
139
151
|
export { MetricStrip, Metric } from "./metric-strip";
|
|
140
152
|
export type { MetricStripProps, MetricProps } from "./metric-strip";
|
|
141
153
|
|