@tangle-network/ui 11.22.2 → 11.23.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 +12 -0
- package/dist/primitives/switch.js +2 -2
- package/dist/primitives/tag.d.ts +6 -0
- package/dist/primitives/tag.js +22 -7
- package/package.json +2 -2
- package/src/primitives/chip.test.tsx +22 -0
- package/src/primitives/switch.test.tsx +7 -3
- package/src/primitives/switch.tsx +8 -2
- package/src/primitives/tag-chip.stories.tsx +24 -0
- package/src/primitives/tag.tsx +33 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @tangle-network/ui
|
|
2
2
|
|
|
3
|
+
## 11.23.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 57f4e3d: `Tag` takes `wrap` to break a long label across lines instead of truncating it, for text that must be read in full without a hover (model-written part names on a phone or on paper).
|
|
8
|
+
|
|
9
|
+
## 11.22.3
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 191de4b: Switch: draw the on state as an `--accent-text` track carrying a `bg-card` thumb. The `bg-primary` track measured 2.4:1 against the dark card and canvas, and its `bg-background` thumb 2.5:1 against the track, so a checked switch did not clear the 3:1 non-text floor in dark themes. Accent text clears 3:1 against the card and the canvas in every theme, which `@tangle-network/brand` now asserts per theme.
|
|
14
|
+
|
|
3
15
|
## 11.22.2
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -6,10 +6,10 @@ import { jsx } from "react/jsx-runtime";
|
|
|
6
6
|
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
|
7
7
|
//#region src/primitives/switch.tsx
|
|
8
8
|
const Switch = React.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx(SwitchPrimitives.Root, {
|
|
9
|
-
className: cn("peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 shadow-sm transition-[background-color,border-color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-transparent data-[state=checked]:bg-
|
|
9
|
+
className: cn("peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 shadow-sm transition-[background-color,border-color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-transparent data-[state=checked]:bg-[var(--accent-text)] data-[state=unchecked]:border-muted-foreground data-[state=unchecked]:bg-transparent", focusRing, className),
|
|
10
10
|
...props,
|
|
11
11
|
ref,
|
|
12
|
-
children: /* @__PURE__ */ jsx(SwitchPrimitives.Thumb, { className: cn("pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-[translate,background-color] data-[state=checked]:translate-x-4 data-[state=checked]:bg-
|
|
12
|
+
children: /* @__PURE__ */ jsx(SwitchPrimitives.Thumb, { className: cn("pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-[translate,background-color] data-[state=checked]:translate-x-4 data-[state=checked]:bg-card data-[state=unchecked]:translate-x-0 data-[state=unchecked]:bg-muted-foreground") })
|
|
13
13
|
}));
|
|
14
14
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
|
15
15
|
//#endregion
|
package/dist/primitives/tag.d.ts
CHANGED
|
@@ -30,6 +30,12 @@ export interface TagProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "c
|
|
|
30
30
|
* the label is a string; pass it when the label is not plain text.
|
|
31
31
|
*/
|
|
32
32
|
removeLabel?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Wraps a long label onto more lines instead of truncating it. Use it where
|
|
35
|
+
* the full text matters and no hover is available to read a `title`, such
|
|
36
|
+
* as model-written part names read on a phone or on paper.
|
|
37
|
+
*/
|
|
38
|
+
wrap?: boolean;
|
|
33
39
|
}
|
|
34
40
|
declare const Tag: React.ForwardRefExoticComponent<TagProps & React.RefAttributes<HTMLSpanElement>>;
|
|
35
41
|
//#endregion
|
package/dist/primitives/tag.js
CHANGED
|
@@ -6,23 +6,38 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
import { X } from "lucide-react";
|
|
7
7
|
//#region src/primitives/tag.tsx
|
|
8
8
|
const SIZE = {
|
|
9
|
-
sm: "
|
|
10
|
-
md: "
|
|
9
|
+
sm: "gap-1 px-2 text-xs [&_svg]:size-3",
|
|
10
|
+
md: "gap-1.5 px-2.5 text-sm [&_svg]:size-3.5"
|
|
11
11
|
};
|
|
12
|
-
const
|
|
12
|
+
const WRAP = {
|
|
13
|
+
sm: {
|
|
14
|
+
box: "items-start",
|
|
15
|
+
label: "py-[3px]",
|
|
16
|
+
icon: "h-[22px] items-center"
|
|
17
|
+
},
|
|
18
|
+
md: {
|
|
19
|
+
box: "items-start",
|
|
20
|
+
label: "py-[3px]",
|
|
21
|
+
icon: "h-[26px] items-center"
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const Tag = React.forwardRef(({ className, children, tone = "neutral", emphasis = "soft", size = "sm", icon, onRemove, removeLabel, wrap = false, ...props }, ref) => {
|
|
13
25
|
const classes = TONE_CLASSES[tone];
|
|
14
26
|
const label = removeLabel ?? (typeof children === "string" ? `Remove ${children}` : "Remove");
|
|
15
27
|
return /* @__PURE__ */ jsxs("span", {
|
|
16
28
|
ref,
|
|
17
|
-
className: cn("inline-flex min-w-0 max-w-full shrink-0
|
|
29
|
+
className: cn("inline-flex min-w-0 max-w-full shrink-0 rounded-md border font-medium", SIZE[size], wrap ? WRAP[size].box : ["items-center", size === "sm" ? "h-6" : "h-7"], emphasis === "outline" ? classes.outline : classes.surface, onRemove && (size === "sm" ? "pr-0.5" : "pr-1"), className),
|
|
18
30
|
...props,
|
|
19
31
|
children: [
|
|
20
32
|
icon ? /* @__PURE__ */ jsx("span", {
|
|
21
33
|
"aria-hidden": "true",
|
|
22
|
-
className: cn("inline-flex shrink-0", classes.icon),
|
|
34
|
+
className: cn("inline-flex shrink-0", wrap && WRAP[size].icon, classes.icon),
|
|
23
35
|
children: icon
|
|
24
36
|
}) : null,
|
|
25
|
-
/* @__PURE__ */ jsx("span", {
|
|
37
|
+
wrap ? /* @__PURE__ */ jsx("span", {
|
|
38
|
+
className: cn("min-w-0 [overflow-wrap:anywhere]", WRAP[size].label),
|
|
39
|
+
children
|
|
40
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
26
41
|
className: "min-w-0 truncate",
|
|
27
42
|
title: typeof children === "string" ? children : void 0,
|
|
28
43
|
children
|
|
@@ -31,7 +46,7 @@ const Tag = React.forwardRef(({ className, children, tone = "neutral", emphasis
|
|
|
31
46
|
type: "button",
|
|
32
47
|
"aria-label": label,
|
|
33
48
|
onClick: onRemove,
|
|
34
|
-
className: cn("relative inline-flex shrink-0 items-center justify-center rounded-sm opacity-70 transition-[opacity,background-color] duration-150 ease-out hover:bg-[color-mix(in_srgb,currentColor_14%,transparent)] hover:opacity-100 before:absolute before:-inset-1.5 before:content-['']", size === "sm" ? "size-5" : "size-6", focusRing),
|
|
49
|
+
className: cn("relative inline-flex shrink-0 items-center justify-center rounded-sm opacity-70 transition-[opacity,background-color] duration-150 ease-out hover:bg-[color-mix(in_srgb,currentColor_14%,transparent)] hover:opacity-100 before:absolute before:-inset-1.5 before:content-['']", size === "sm" ? "size-5" : "size-6", wrap && "mt-px", focusRing),
|
|
35
50
|
children: /* @__PURE__ */ jsx(X, { "aria-hidden": "true" })
|
|
36
51
|
}) : null
|
|
37
52
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/ui",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.23.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",
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
"peerDependencies": {
|
|
150
150
|
"@hocuspocus/provider": ">=2.15.0 <5.0.0",
|
|
151
151
|
"@nanostores/react": ">=0.8.4 <3.0.0",
|
|
152
|
-
"@tangle-network/brand": "^1.15.
|
|
152
|
+
"@tangle-network/brand": "^1.15.6",
|
|
153
153
|
"@tiptap/core": ">=3.20.4 <4.0.0",
|
|
154
154
|
"@tiptap/extension-collaboration": ">=3.20.4 <4.0.0",
|
|
155
155
|
"@tiptap/extension-collaboration-caret": ">=3.7.0 <4.0.0",
|
|
@@ -124,6 +124,28 @@ describe("Tag", () => {
|
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
describe("Tag wrap", () => {
|
|
128
|
+
const LONG = "Adafruit-BME680-Temperature-Humidity-Pressure-Gas-Sensor-Breakout";
|
|
129
|
+
|
|
130
|
+
it("truncates a long label by default and names it in a title", () => {
|
|
131
|
+
render(<Tag>{LONG}</Tag>);
|
|
132
|
+
const label = screen.getByText(LONG);
|
|
133
|
+
expect(label.className).toContain("truncate");
|
|
134
|
+
expect(label.getAttribute("title")).toBe(LONG);
|
|
135
|
+
expect(label.parentElement?.className).toContain("h-6");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("wraps a long label across lines when asked, aligned to its first line", () => {
|
|
139
|
+
render(<Tag wrap size="md">{LONG}</Tag>);
|
|
140
|
+
const label = screen.getByText(LONG);
|
|
141
|
+
expect(label.className).not.toContain("truncate");
|
|
142
|
+
expect(label.className).toContain("[overflow-wrap:anywhere]");
|
|
143
|
+
expect(label.className).toContain("py-[3px]");
|
|
144
|
+
expect(label.parentElement?.className).toContain("items-start");
|
|
145
|
+
expect(label.parentElement?.className).not.toMatch(/(^|\s)h-7(\s|$)/);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
127
149
|
describe("IconTile", () => {
|
|
128
150
|
it("falls back from a broken image to the glyph, then to initials", () => {
|
|
129
151
|
const { container, rerender } = render(<IconTile src="https://example.invalid/logo.png" name="Acme Robotics" />);
|
|
@@ -24,12 +24,16 @@ describe("Switch", () => {
|
|
|
24
24
|
expect(stateClasses(thumb, "unchecked")).not.toContain("bg-background");
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
it("
|
|
27
|
+
it("draws its on state as an accent-text track with a card thumb, so a checked switch clears 3:1 in every theme", () => {
|
|
28
28
|
render(<Switch aria-label="Reminders" />);
|
|
29
29
|
const track = screen.getByRole("switch", { name: "Reminders" });
|
|
30
30
|
fireEvent.click(track);
|
|
31
31
|
expect(track).toHaveAttribute("data-state", "checked");
|
|
32
|
-
|
|
33
|
-
expect(stateClasses(track
|
|
32
|
+
const thumb = track.firstElementChild as Element;
|
|
33
|
+
expect(stateClasses(track, "checked")).toContain("bg-[var(--accent-text)]");
|
|
34
|
+
expect(stateClasses(track, "checked")).toContain("border-transparent");
|
|
35
|
+
expect(stateClasses(track, "checked")).not.toContain("bg-primary");
|
|
36
|
+
expect(stateClasses(thumb, "checked")).toContain("bg-card");
|
|
37
|
+
expect(stateClasses(thumb, "checked")).not.toContain("bg-background");
|
|
34
38
|
});
|
|
35
39
|
});
|
|
@@ -16,7 +16,13 @@ const Switch = React.forwardRef<
|
|
|
16
16
|
// switch's boundary is non-text content). The old bg-input track under a
|
|
17
17
|
// bg-background thumb read as one flat shape, so an off switch was not
|
|
18
18
|
// visible at all. --border-strong was tried and measured 2.2:1 on a white card.
|
|
19
|
-
|
|
19
|
+
// On is an --accent-text track carrying a card-coloured thumb. Accent text
|
|
20
|
+
// is held to text contrast on the card and the canvas, so the track's edge
|
|
21
|
+
// and the thumb on it (the same pair, inverted) both clear 3:1 in every
|
|
22
|
+
// theme. The bg-primary fill is tuned to carry white text and measured
|
|
23
|
+
// 2.4:1 on the dark card, with a bg-background thumb at 2.5:1 on it.
|
|
24
|
+
// brand's switch-contrast.test.ts asserts these pairs for every theme.
|
|
25
|
+
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 shadow-sm transition-[background-color,border-color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-transparent data-[state=checked]:bg-[var(--accent-text)] data-[state=unchecked]:border-muted-foreground data-[state=unchecked]:bg-transparent",
|
|
20
26
|
focusRing,
|
|
21
27
|
className,
|
|
22
28
|
)}
|
|
@@ -25,7 +31,7 @@ const Switch = React.forwardRef<
|
|
|
25
31
|
>
|
|
26
32
|
<SwitchPrimitives.Thumb
|
|
27
33
|
className={cn(
|
|
28
|
-
"pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-[translate,background-color] data-[state=checked]:translate-x-4 data-[state=checked]:bg-
|
|
34
|
+
"pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-[translate,background-color] data-[state=checked]:translate-x-4 data-[state=checked]:bg-card data-[state=unchecked]:translate-x-0 data-[state=unchecked]:bg-muted-foreground",
|
|
29
35
|
)}
|
|
30
36
|
/>
|
|
31
37
|
</SwitchPrimitives.Root>
|
|
@@ -224,3 +224,27 @@ export const WorstCase: Story = {
|
|
|
224
224
|
</BothThemes>
|
|
225
225
|
),
|
|
226
226
|
};
|
|
227
|
+
|
|
228
|
+
const PART = "Adafruit-BME680-Temperature-Humidity-Pressure-Gas-Sensor-Breakout";
|
|
229
|
+
|
|
230
|
+
/** `wrap` keeps the whole label readable: one line matches a fixed tag's height; more lines grow it, with the icon and remove button on the first line. */
|
|
231
|
+
export const Wrap: Story = {
|
|
232
|
+
render: () => (
|
|
233
|
+
<BothThemes>
|
|
234
|
+
<div className="flex w-full max-w-[220px] flex-col items-start gap-2" data-testid="wrap-column">
|
|
235
|
+
{(["sm", "md"] as const).map((size) => (
|
|
236
|
+
<div key={size} className="flex flex-col items-start gap-2">
|
|
237
|
+
<div className="flex items-start gap-2">
|
|
238
|
+
<Tag size={size} data-probe={`fixed-${size}`}>Short</Tag>
|
|
239
|
+
<Tag size={size} wrap data-probe={`wrap-one-${size}`}>Short</Tag>
|
|
240
|
+
<Tag size={size} wrap onRemove={() => {}} data-probe={`wrap-remove-${size}`}>Short</Tag>
|
|
241
|
+
</div>
|
|
242
|
+
<Tag size={size} wrap tone="teal" icon={<FileText />} onRemove={() => {}} data-probe={`wrap-long-${size}`}>
|
|
243
|
+
{PART}
|
|
244
|
+
</Tag>
|
|
245
|
+
</div>
|
|
246
|
+
))}
|
|
247
|
+
</div>
|
|
248
|
+
</BothThemes>
|
|
249
|
+
),
|
|
250
|
+
};
|
package/src/primitives/tag.tsx
CHANGED
|
@@ -33,16 +33,31 @@ export interface TagProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "c
|
|
|
33
33
|
* the label is a string; pass it when the label is not plain text.
|
|
34
34
|
*/
|
|
35
35
|
removeLabel?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Wraps a long label onto more lines instead of truncating it. Use it where
|
|
38
|
+
* the full text matters and no hover is available to read a `title`, such
|
|
39
|
+
* as model-written part names read on a phone or on paper.
|
|
40
|
+
*/
|
|
41
|
+
wrap?: boolean;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
const SIZE = {
|
|
39
|
-
sm: "
|
|
40
|
-
md: "
|
|
45
|
+
sm: "gap-1 px-2 text-xs [&_svg]:size-3",
|
|
46
|
+
md: "gap-1.5 px-2.5 text-sm [&_svg]:size-3.5",
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
// A truncating tag has one fixed height. A wrapping tag carries its vertical
|
|
50
|
+
// padding on the label instead, sized so one line is exactly that height
|
|
51
|
+
// (sm: 16px line + 6 + 2px border = 24; md: 20 + 6 + 2 = 28), and aligns the
|
|
52
|
+
// icon and remove button to the first line rather than the middle.
|
|
53
|
+
const WRAP = {
|
|
54
|
+
sm: { box: "items-start", label: "py-[3px]", icon: "h-[22px] items-center" },
|
|
55
|
+
md: { box: "items-start", label: "py-[3px]", icon: "h-[26px] items-center" },
|
|
41
56
|
} as const;
|
|
42
57
|
|
|
43
58
|
const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
|
|
44
59
|
(
|
|
45
|
-
{ className, children, tone = "neutral", emphasis = "soft", size = "sm", icon, onRemove, removeLabel, ...props },
|
|
60
|
+
{ className, children, tone = "neutral", emphasis = "soft", size = "sm", icon, onRemove, removeLabel, wrap = false, ...props },
|
|
46
61
|
ref,
|
|
47
62
|
) => {
|
|
48
63
|
const classes = TONE_CLASSES[tone];
|
|
@@ -51,8 +66,9 @@ const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
|
|
|
51
66
|
<span
|
|
52
67
|
ref={ref}
|
|
53
68
|
className={cn(
|
|
54
|
-
"inline-flex min-w-0 max-w-full shrink-0
|
|
69
|
+
"inline-flex min-w-0 max-w-full shrink-0 rounded-md border font-medium",
|
|
55
70
|
SIZE[size],
|
|
71
|
+
wrap ? WRAP[size].box : ["items-center", size === "sm" ? "h-6" : "h-7"],
|
|
56
72
|
emphasis === "outline" ? classes.outline : classes.surface,
|
|
57
73
|
onRemove && (size === "sm" ? "pr-0.5" : "pr-1"),
|
|
58
74
|
className,
|
|
@@ -60,15 +76,20 @@ const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
|
|
|
60
76
|
{...props}
|
|
61
77
|
>
|
|
62
78
|
{icon ? (
|
|
63
|
-
<span aria-hidden="true" className={cn("inline-flex shrink-0", classes.icon)}>
|
|
79
|
+
<span aria-hidden="true" className={cn("inline-flex shrink-0", wrap && WRAP[size].icon, classes.icon)}>
|
|
64
80
|
{icon}
|
|
65
81
|
</span>
|
|
66
82
|
) : null}
|
|
67
|
-
{/*
|
|
68
|
-
out
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
83
|
+
{/* By default a long label truncates here rather than pushing the
|
|
84
|
+
remove button out, and the title gives its full text on hover.
|
|
85
|
+
`wrap` breaks it across lines instead, even inside one long token. */}
|
|
86
|
+
{wrap ? (
|
|
87
|
+
<span className={cn("min-w-0 [overflow-wrap:anywhere]", WRAP[size].label)}>{children}</span>
|
|
88
|
+
) : (
|
|
89
|
+
<span className="min-w-0 truncate" title={typeof children === "string" ? children : undefined}>
|
|
90
|
+
{children}
|
|
91
|
+
</span>
|
|
92
|
+
)}
|
|
72
93
|
{onRemove ? (
|
|
73
94
|
<button
|
|
74
95
|
type="button"
|
|
@@ -79,6 +100,8 @@ const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
|
|
|
79
100
|
// the hit area without moving the tag's layout.
|
|
80
101
|
"relative inline-flex shrink-0 items-center justify-center rounded-sm opacity-70 transition-[opacity,background-color] duration-150 ease-out hover:bg-[color-mix(in_srgb,currentColor_14%,transparent)] hover:opacity-100 before:absolute before:-inset-1.5 before:content-['']",
|
|
81
102
|
size === "sm" ? "size-5" : "size-6",
|
|
103
|
+
// Centred on the first line box (22px sm, 26px md) when wrapping.
|
|
104
|
+
wrap && "mt-px",
|
|
82
105
|
focusRing,
|
|
83
106
|
)}
|
|
84
107
|
>
|