Skip to content

Commit 1ae0db8

Browse files
chef-ericclaude
andauthored
feat: add basic components and widgets (#4)
Components: Alert, ButtonMenu, Toast; TabMenu text variant; TableView sort button + Table tweaks; Button theme + component index/theme updates. Widgets: DropdownMenu, PortfolioBreakdown, TokenDisplay, WalletAvatar. Also adds shared Icons, Storybook preview setup, and package deps. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 02d8164 commit 1ae0db8

44 files changed

Lines changed: 1628 additions & 22 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.storybook/preview-head.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<link rel="preconnect" href="https://fonts.googleapis.com">
2+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
3+
<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@400;500;600;700;800&display=swap" rel="stylesheet">

.storybook/preview.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ const withTheme: Decorator = (Story, context) => {
1010
return (
1111
<ThemeProvider forcedTheme={theme}>
1212
<SCThemeProvider theme={pcsTheme}>
13-
<div style={{ background: 'var(--pcs-colors-background)', minHeight: '100%' }}>
14-
<Story />
15-
</div>
13+
<Story />
1614
</SCThemeProvider>
1715
</ThemeProvider>
1816
)

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"primereact": "^10.9.7",
2222
"react": "^19.2.4",
2323
"react-dom": "^19.2.4",
24+
"sonner": "^2.0.7",
2425
"styled-components": "^6.4.0",
2526
"styled-system": "^5.1.5"
2627
},

pnpm-lock.yaml

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/ui/Icons.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2052,6 +2052,57 @@ export function LayersIcon(p: IconProps) {
20522052
)
20532053
}
20542054

2055+
/**
2056+
* PixelAvatarIcon — Blockie-style pixel art avatar clipped to a circle.
2057+
* Recreated from the Figma Wallet design. Uses PCS primary (#1FC7D4) and failure (#ED4B9E).
2058+
*/
2059+
export function PixelAvatarIcon({ size = 20, ...props }: IconProps) {
2060+
const C = '#1FC7D4'
2061+
const P = '#ED4B9E'
2062+
// 8×8 pixel grid
2063+
const grid = [
2064+
[C,C,P,P,P,P,C,C],
2065+
[C,P,C,P,P,C,P,C],
2066+
[P,C,P,P,P,P,C,P],
2067+
[P,P,P,C,C,P,P,P],
2068+
[P,P,C,C,C,C,P,P],
2069+
[C,P,P,P,P,P,P,C],
2070+
[C,C,P,C,C,P,C,C],
2071+
[C,C,C,P,P,C,C,C],
2072+
]
2073+
const cellSize = 40 / 8
2074+
return (
2075+
<svg
2076+
width={size}
2077+
height={size}
2078+
viewBox="0 0 40 40"
2079+
xmlns="http://www.w3.org/2000/svg"
2080+
aria-hidden={props['aria-label'] ? undefined : true}
2081+
{...props}
2082+
>
2083+
<defs>
2084+
<clipPath id="pixel-avatar-circle">
2085+
<circle cx="20" cy="20" r="20" />
2086+
</clipPath>
2087+
</defs>
2088+
<g clipPath="url(#pixel-avatar-circle)">
2089+
{grid.map((row, y) =>
2090+
row.map((color, x) => (
2091+
<rect
2092+
key={`${x}-${y}`}
2093+
x={x * cellSize}
2094+
y={y * cellSize}
2095+
width={cellSize}
2096+
height={cellSize}
2097+
fill={color}
2098+
/>
2099+
))
2100+
)}
2101+
</g>
2102+
</svg>
2103+
)
2104+
}
2105+
20552106
export function ActivityIcon(p: IconProps) {
20562107
return (
20572108
<Icon {...p}>

src/ui/components/Alert/Alert.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import React from "react";
2+
import type { DefaultTheme } from "styled-components";
3+
import { styled } from "styled-components";
4+
import { Flex } from "../_pcs-shims";
5+
import { IconButton } from "../Button";
6+
import {
7+
CloseIcon,
8+
BlockIcon,
9+
ErrorIcon,
10+
CheckmarkCircleIcon,
11+
InfoIcon,
12+
} from "../../Icons";
13+
import { Text } from "../Text";
14+
import type { AlertProps } from "./types";
15+
import { variants } from "./types";
16+
17+
interface ThemedIconLabel {
18+
variant: AlertProps["variant"];
19+
theme: DefaultTheme;
20+
}
21+
22+
const getThemeColor = ({ theme, variant = variants.INFO }: ThemedIconLabel) => {
23+
switch (variant) {
24+
case variants.DANGER:
25+
return theme.colors.failure;
26+
case variants.WARNING:
27+
return theme.colors.warning;
28+
case variants.SUCCESS:
29+
return theme.colors.success;
30+
case variants.INFO:
31+
default:
32+
return theme.colors.secondary;
33+
}
34+
};
35+
36+
const getIcon = (variant: AlertProps["variant"] = variants.INFO) => {
37+
switch (variant) {
38+
case variants.DANGER:
39+
return BlockIcon;
40+
case variants.WARNING:
41+
return ErrorIcon;
42+
case variants.SUCCESS:
43+
return CheckmarkCircleIcon;
44+
case variants.INFO:
45+
default:
46+
return InfoIcon;
47+
}
48+
};
49+
50+
const IconLabel = styled.div<ThemedIconLabel>`
51+
background-color: ${getThemeColor};
52+
border-radius: 16px 0 0 16px;
53+
color: ${({ theme }) => theme.alert.background};
54+
padding: 12px;
55+
`;
56+
57+
const withHandlerSpacing = 32 + 12 + 8;
58+
const Details = styled.div<{ $hasHandler: boolean }>`
59+
flex: 1;
60+
padding-bottom: 12px;
61+
padding-left: 12px;
62+
padding-right: ${({ $hasHandler }) => ($hasHandler ? `${withHandlerSpacing}px` : "12px")};
63+
padding-top: 12px;
64+
`;
65+
66+
const CloseHandler = styled.div`
67+
border-radius: 0 16px 16px 0;
68+
right: 8px;
69+
position: absolute;
70+
top: 8px;
71+
`;
72+
73+
const StyledAlert = styled(Flex)`
74+
position: relative;
75+
background-color: ${({ theme }) => theme.alert.background};
76+
border-radius: 16px;
77+
box-shadow: 0px 20px 36px -8px rgba(14, 14, 44, 0.1), 0px 1px 1px rgba(0, 0, 0, 0.05);
78+
`;
79+
80+
const Alert: React.FC<React.PropsWithChildren<AlertProps>> = ({ title, children, variant, onClick }) => {
81+
const Icon = getIcon(variant);
82+
83+
return (
84+
<StyledAlert>
85+
<IconLabel variant={variant}>
86+
<Icon color="currentColor" width="24px" />
87+
</IconLabel>
88+
<Details $hasHandler={!!onClick}>
89+
{typeof title === "string" ? <Text bold>{title}</Text> : title}
90+
{typeof children === "string" ? (
91+
<Text style={{ wordBreak: "break-word" }} as="p">
92+
{children}
93+
</Text>
94+
) : (
95+
children
96+
)}
97+
</Details>
98+
{onClick && (
99+
<CloseHandler>
100+
<IconButton scale="sm" variant="text" onClick={onClick} aria-label="Close">
101+
<CloseIcon width="24px" color="currentColor" />
102+
</IconButton>
103+
</CloseHandler>
104+
)}
105+
</StyledAlert>
106+
);
107+
};
108+
109+
export default Alert;

src/ui/components/Alert/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { default as Alert } from "./Alert";
2+
export { variants as alertVariants } from "./types";
3+
export type { AlertProps } from "./types";
4+
export type { Variants as AlertVariants } from "./types";

src/ui/components/Alert/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { MouseEvent, ReactNode } from "react";
2+
3+
export const variants = {
4+
INFO: "info",
5+
DANGER: "danger",
6+
SUCCESS: "success",
7+
WARNING: "warning",
8+
} as const;
9+
10+
export type Variants = (typeof variants)[keyof typeof variants];
11+
12+
export interface AlertProps {
13+
variant?: Variants;
14+
title: string | ReactNode;
15+
children?: ReactNode;
16+
onClick?: (evt: MouseEvent<HTMLButtonElement>) => void;
17+
}

src/ui/components/Button/theme.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ export const styleVariants = {
7070
backgroundColor: "input",
7171
color: "textSubtle",
7272
boxShadow: "none",
73+
border: "1px solid",
74+
borderColor: "inputSecondary",
75+
borderBottomWidth: "2px",
7376
},
7477
[variants.BUBBLEGUM]: {
7578
background: vars.colors.gradientBubblegum,
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import { expect, fn } from "storybook/test";
3+
import { useState } from "react";
4+
import { ButtonMenu } from "./ButtonMenu";
5+
import { ButtonMenuItem } from "./ButtonMenu";
6+
7+
const meta: Meta<typeof ButtonMenu> = {
8+
title: "Components/ButtonMenu",
9+
component: ButtonMenu,
10+
tags: ["autodocs"],
11+
};
12+
13+
export default meta;
14+
type Story = StoryObj<typeof ButtonMenu>;
15+
16+
export const Default: Story = {
17+
args: {
18+
activeIndex: 0,
19+
onItemClick: fn(),
20+
children: [
21+
<ButtonMenuItem key="1">Button 1</ButtonMenuItem>,
22+
<ButtonMenuItem key="2">Button 2</ButtonMenuItem>,
23+
<ButtonMenuItem key="3">Button 3</ButtonMenuItem>,
24+
],
25+
},
26+
play: async ({ canvas, args }) => {
27+
await expect(canvas.getByText("Button 1")).toBeInTheDocument();
28+
await expect(canvas.getByText("Button 2")).toBeInTheDocument();
29+
await canvas.getByText("Button 2").click();
30+
await expect(args.onItemClick).toHaveBeenCalledWith(1, expect.anything());
31+
},
32+
};
33+
34+
export const Subtle: Story = {
35+
args: {
36+
activeIndex: 1,
37+
variant: "subtle",
38+
children: [
39+
<ButtonMenuItem key="1">Option A</ButtonMenuItem>,
40+
<ButtonMenuItem key="2">Option B</ButtonMenuItem>,
41+
<ButtonMenuItem key="3">Option C</ButtonMenuItem>,
42+
],
43+
},
44+
};
45+
46+
export const SmallScale: Story = {
47+
args: {
48+
activeIndex: 0,
49+
scale: "sm",
50+
children: [
51+
<ButtonMenuItem key="1">Small 1</ButtonMenuItem>,
52+
<ButtonMenuItem key="2">Small 2</ButtonMenuItem>,
53+
],
54+
},
55+
};
56+
57+
export const FullWidth: Story = {
58+
args: {
59+
activeIndex: 0,
60+
fullWidth: true,
61+
children: [
62+
<ButtonMenuItem key="1">Tab 1</ButtonMenuItem>,
63+
<ButtonMenuItem key="2">Tab 2</ButtonMenuItem>,
64+
<ButtonMenuItem key="3">Tab 3</ButtonMenuItem>,
65+
],
66+
},
67+
decorators: [
68+
(Story) => (
69+
<div style={{ width: 600 }}>
70+
<Story />
71+
</div>
72+
),
73+
],
74+
};
75+
76+
export const Disabled: Story = {
77+
args: {
78+
activeIndex: 0,
79+
disabled: true,
80+
children: [
81+
<ButtonMenuItem key="1">Disabled 1</ButtonMenuItem>,
82+
<ButtonMenuItem key="2">Disabled 2</ButtonMenuItem>,
83+
],
84+
},
85+
};
86+
87+
function InteractiveDemo() {
88+
const [index, setIndex] = useState(0);
89+
return (
90+
<ButtonMenu activeIndex={index} onItemClick={setIndex}>
91+
<ButtonMenuItem>Market</ButtonMenuItem>
92+
<ButtonMenuItem>Limit</ButtonMenuItem>
93+
<ButtonMenuItem>TWAP</ButtonMenuItem>
94+
</ButtonMenu>
95+
);
96+
}
97+
98+
export const Interactive: Story = {
99+
render: () => <InteractiveDemo />,
100+
play: async ({ canvas }) => {
101+
await canvas.getByText("Limit").click();
102+
// After click, "Limit" should become the active button
103+
await expect(canvas.getByText("Limit")).toBeInTheDocument();
104+
},
105+
};

0 commit comments

Comments
 (0)