@tangle-network/ui 11.16.11 → 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.
@@ -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 };
@@ -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