Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/metal-cows-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"thirdweb": minor
---

Add headless UI component: Account (Name, Image, Address, Balance)
16 changes: 16 additions & 0 deletions apps/portal/src/app/react/v5/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,22 @@ export const sidebar: SideBar = {
href: `${slug}/${name}`,
icon: <CodeIcon />,
})),
{
name: "Account",
isCollapsible: true,
links: [
"AccountProvider",
"AccountAddress",
"AccountAvatar",
"AccountName",
"AccountBlobbie",
"AccountBalance",
].map((name) => ({
name,
href: `${slug}/${name}`,
icon: <CodeIcon />,
})),
},
],
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const tagsToGroup = {
"@social": "Social API",
"@modules": "Modules",
"@client": "Client",
"@account": "Account Components",
} as const;

type TagKey = keyof typeof tagsToGroup;
Expand Down Expand Up @@ -79,6 +80,7 @@ const sidebarGroupOrder: TagKey[] = [
"@theme",
"@utils",
"@others",
"@account",
];

function findTag(
Expand Down
23 changes: 23 additions & 0 deletions packages/thirdweb/src/exports/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,26 @@ export type {
// Site Embed and Linking
export { SiteEmbed } from "../react/web/ui/SiteEmbed.js";
export { SiteLink } from "../react/web/ui/SiteLink.js";

// Account
export {
AccountAddress,
type AccountAddressProps,
} from "../react/web/ui/prebuilt/Account/address.js";
export {
AccountBalance,
type AccountBalanceProps,
} from "../react/web/ui/prebuilt/Account/balance.js";
export {
AccountName,
type AccountNameProps,
} from "../react/web/ui/prebuilt/Account/name.js";
export { AccountBlobbie } from "../react/web/ui/prebuilt/Account/blobbie.js";
export {
AccountProvider,
type AccountProviderProps,
} from "../react/web/ui/prebuilt/Account/provider.js";
export {
AccountAvatar,
type AccountAvatarProps,
} from "../react/web/ui/prebuilt/Account/avatar.js";
34 changes: 28 additions & 6 deletions packages/thirdweb/src/react/web/ui/ConnectWallet/Blobbie.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,34 @@ const COLOR_OPTIONS = [
["#fda4af", "#be123c"],
];

/**
* Props for the Blobbie component
* @component
*/
export type BlobbieProps = {
address: Address;
style?: Omit<React.CSSProperties, "backgroundImage">;
className?: string;
size?: number;
};

/**
* A unique gradient avatar based on the provided address.
* @param props The component props.
* @param props.address The address to generate the gradient with.
* @param props.size The size of each side of the square avatar (in pixels)
* @param props.style The CSS style for the component - excluding `backgroundImage`
* @param props.className The className for the component
* @param props.size The size of each side of the square avatar (in pixels). This prop will override the `width` and `height` attributes from the `style` prop.
* @component
* @wallet
* @example
* ```tsx
* <Blobbie address="0x...." size={24} />
* import { Blobbie } from "thirdweb/react";
*
* <Blobbie address="0x...." className="w-10 h-10" />
* ```
* @wallet
*/
export function Blobbie(props: { address: Address; size: number }) {
export function Blobbie(props: BlobbieProps) {
const id = useId();
const colors = useMemo(
() =>
Expand All @@ -46,10 +62,16 @@ export function Blobbie(props: { address: Address; size: number }) {
<div
id={id}
style={{
width: `${props.size}px`,
height: `${props.size}px`,
...props.style,
backgroundImage: `radial-gradient(ellipse at left bottom, ${colors[0]}, ${colors[1]})`,
...(props.size
? {
width: `${props.size}px`,
height: `${props.size}px`,
}
: undefined),
}}
className={props.className}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { render, screen, waitFor } from "~test/react-render.js";
import { TEST_CLIENT } from "~test/test-clients.js";
import { shortenAddress } from "../../../../../utils/address.js";
import { AccountAddress } from "./address.js";
import { AccountProvider } from "./provider.js";

describe.runIf(process.env.TW_SECRET_KEY)("AccountAddress component", () => {
it("should format the address properly", () => {
render(
<AccountProvider
address="0x12345674b599ce99958242b3D3741e7b01841DF3"
client={TEST_CLIENT}
>
<AccountAddress formatFn={shortenAddress} />
</AccountProvider>,
);

waitFor(() =>
expect(
screen.getByText("0x1234...1DF3", {
exact: true,
selector: "span",
}),
).toBeInTheDocument(),
);
});
});
64 changes: 64 additions & 0 deletions packages/thirdweb/src/react/web/ui/prebuilt/Account/address.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"use client";

import { useAccountContext } from "./provider.js";

/**
* @component
* @account
*/
export interface AccountAddressProps
extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
/**
* The function used to transform (format) the wallet address
* Specifically useful for shortening the wallet.
*
* This function should take in a string and output a string
*/
formatFn?: (str: string) => string;
}

/**
*
* @returns a <span> containing the full wallet address of the account
*
* @example
* ### Basic usage
* ```tsx
* import { AccountProvider, AccountAddress } from "thirdweb/react";
*
* <AccountProvider address="0x12345674b599ce99958242b3D3741e7b01841DF3" client={TW_CLIENT}>
* <AccountAddress />
* </AccountProvider>
* ```
* Result:
* ```html
* <span>0x12345674b599ce99958242b3D3741e7b01841DF3</span>
* ```
*
*
* ### Shorten the address
* ```tsx
* import { AccountProvider, AccountAddress } from "thirdweb/react";
* import { shortenAddress } from "thirdweb/utils";
*
* <AccountProvider address="0x12345674b599ce99958242b3D3741e7b01841DF3" client={TW_CLIENT}>
* <AccountAddress formatFn={shortenAddress} />
* </AccountProvider>
* ```
* Result:
* ```html
* <span>0x1234...1DF3</span>
* ```
*
* @component
* @account
* @beta
*/
export function AccountAddress({
formatFn,
...restProps
}: AccountAddressProps) {
const { address } = useAccountContext();
const value = formatFn ? formatFn(address) : address;
return <span {...restProps}>{value}</span>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { render, screen, waitFor } from "~test/react-render.js";
import { TEST_CLIENT } from "~test/test-clients.js";
import { TEST_ACCOUNT_A } from "~test/test-wallets.js";
import { AccountAvatar } from "./avatar.js";
import { AccountProvider } from "./provider.js";

describe.runIf(process.env.TW_SECRET_KEY)("AccountAvatar component", () => {
it("should render an image", () => {
render(
<AccountProvider
address={"0x12345674b599ce99958242b3D3741e7b01841DF3"}
client={TEST_CLIENT}
>
<AccountAvatar />
</AccountProvider>,
);

waitFor(() => expect(screen.getByRole("img")).toBeInTheDocument());
});

it("should fallback properly if failed to load", () => {
render(
<AccountProvider address={TEST_ACCOUNT_A.address} client={TEST_CLIENT}>
<AccountAvatar fallbackComponent={<span>oops</span>} />
</AccountProvider>,
);

waitFor(() =>
expect(
screen.getByText("oops", {
exact: true,
selector: "span",
}),
).toBeInTheDocument(),
);
});

it("should NOT render anything if fail to resolve avatar", () => {
render(
<AccountProvider address={"invalid-wallet-address"} client={TEST_CLIENT}>
<AccountAvatar />
</AccountProvider>,
);

waitFor(() => expect(screen.getByRole("img")).not.toBeInTheDocument());
});
});
Loading
Loading