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
74 changes: 74 additions & 0 deletions react/src/components/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// SPDX-License-Identifier: MIT
import React, { forwardRef } from "react";
import { theme } from "../theme";

export interface BreadcrumbItem {
label: React.ReactNode;
href?: string;
}

export interface BreadcrumbsProps extends React.HTMLAttributes<HTMLElement> {
items: BreadcrumbItem[];
ariaLabel?: string;
separator?: React.ReactNode;
}

export const Breadcrumbs = forwardRef<HTMLElement, BreadcrumbsProps>(
function Breadcrumbs(
{ items, ariaLabel = "Breadcrumb", separator = "/", style, ...rest },
ref,
) {
return (
<nav
ref={ref}
aria-label={ariaLabel}
style={{ color: theme.neutralText, fontSize: 14, ...style }}
{...rest}
>
<ol
style={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: 8,
listStyle: "none",
margin: 0,
padding: 0,
}}
>
{items.map((item, index) => {
const isCurrent = index === items.length - 1;
return (
<li
key={`${index}-${String(item.label)}`}
style={{
display: "flex",
alignItems: "center",
gap: 8,
minWidth: 0,
}}
>
{index > 0 ? <span aria-hidden="true">{separator}</span> : null}
{item.href && !isCurrent ? (
<a
href={item.href}
style={{ color: theme.primary, textDecoration: "none" }}
>
{item.label}
</a>
) : (
<span
aria-current={isCurrent ? "page" : undefined}
style={{ color: isCurrent ? theme.text : undefined }}
>
{item.label}
</span>
)}
</li>
);
})}
</ol>
</nav>
);
},
);
123 changes: 123 additions & 0 deletions react/src/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// SPDX-License-Identifier: MIT
import React, { forwardRef } from "react";
import { theme } from "../theme";

export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
export type ButtonSize = "sm" | "md" | "lg";

export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
loadingText?: string;
}

const VARIANTS: Record<ButtonVariant, React.CSSProperties> = {
primary: {
backgroundColor: theme.primary,
borderColor: theme.primary,
color: theme.surface,
},
secondary: {
backgroundColor: theme.surface,
borderColor: theme.border,
color: theme.text,
},
danger: {
backgroundColor: theme.danger,
borderColor: theme.danger,
color: theme.surface,
},
ghost: {
backgroundColor: "transparent",
borderColor: "transparent",
color: theme.text,
},
};

const SIZES: Record<ButtonSize, React.CSSProperties> = {
sm: { minHeight: 30, padding: "5px 10px", fontSize: 12 },
md: { minHeight: 38, padding: "8px 14px", fontSize: 14 },
lg: { minHeight: 46, padding: "10px 18px", fontSize: 16 },
};

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "md",
loading = false,
loadingText,
disabled,
style,
children,
...rest
},
ref,
) {
return (
<button
ref={ref}
disabled={disabled || loading}
aria-busy={loading || undefined}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
border: "1px solid",
borderRadius: 6,
fontFamily: "inherit",
fontWeight: 600,
lineHeight: 1.4,
cursor: disabled || loading ? "not-allowed" : "pointer",
opacity: disabled && !loading ? 0.55 : 1,
boxSizing: "border-box",
...VARIANTS[variant],
...SIZES[size],
...style,
}}
{...rest}
>
{loading ? (
<svg
aria-hidden="true"
width="14"
height="14"
viewBox="0 0 24 24"
style={{ flexShrink: 0 }}
>
<g>
<circle
cx="12"
cy="12"
r="9"
fill="none"
stroke="currentColor"
strokeWidth="3"
opacity="0.25"
/>
<path
d="M12 3a9 9 0 0 1 9 9"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
<animateTransform
attributeName="transform"
attributeType="XML"
type="rotate"
from="0 12 12"
to="360 12 12"
dur="0.8s"
repeatCount="indefinite"
/>
</g>
</svg>
) : null}
{loading ? (loadingText ?? children) : children}
</button>
);
},
);
48 changes: 48 additions & 0 deletions react/src/components/COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,54 @@ const [open, setOpen] = useState(false);
<WalletSelectModal open={open} onClose={() => setOpen(false)} />
```

## Button

Button primitive with `primary`, `secondary`, `danger`, and `ghost` variants.
`loading` disables interaction and announces the busy state; `loadingText` can
replace the button label while work is in progress. Native button props and a
ref are forwarded.

```tsx
<Button variant="primary" loading={saving} loadingText="Saving...">Save</Button>
```

## Breadcrumbs

Provide ordered `{ label, href? }` items. The final item is marked as the
current page and rendered as text, while earlier items with an `href` are
links. `ariaLabel` and `separator` customize the navigation landmark.

```tsx
<Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Vaults' }]} />
```

## Footer

Compact footer with optional children and a network latency indicator. Pass
`latencyMs` to show a reading, or `null`/omit it to show unavailable status.
`latencyWarningMs` and `latencyErrorMs` set the warning and error thresholds.

```tsx
<Footer network="Testnet" latencyMs={84}>Build 42</Footer>
```

## Tabs and SegmentedControl

`Tabs` accepts `{ label, value, content, disabled? }` items and supports
controlled (`value`/`onChange`) or uncontrolled (`defaultValue`) selection.
Arrow keys move between enabled tabs; Home and End select the first and last.
`SegmentedControl` uses `{ label, value, disabled? }` options in an accessible
radio group and supports the same controlled/uncontrolled selection pattern.

```tsx
<Tabs items={[
{ label: 'Overview', value: 'overview', content: <Overview /> },
{ label: 'Activity', value: 'activity', content: <Activity /> },
]} />
<SegmentedControl ariaLabel="View" options={[
{ label: 'List', value: 'list' }, { label: 'Grid', value: 'grid' },
]} />
```
## Badge

Small label used for statuses, counts, or categories. When `onClick` is
Expand Down
80 changes: 80 additions & 0 deletions react/src/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: MIT
import React, { forwardRef } from "react";
import { theme } from "../theme";

export interface FooterProps extends React.HTMLAttributes<HTMLElement> {
network?: string;
latencyMs?: number | null;
latencyWarningMs?: number;
latencyErrorMs?: number;
}

export const Footer = forwardRef<HTMLElement, FooterProps>(function Footer(
{
network = "Network",
latencyMs,
latencyWarningMs = 300,
latencyErrorMs = 1000,
children,
style,
...rest
},
ref,
) {
const hasLatency =
typeof latencyMs === "number" &&
Number.isFinite(latencyMs) &&
latencyMs >= 0;
const color = !hasLatency
? theme.neutralText
: latencyMs! >= latencyErrorMs
? theme.danger
: latencyMs! >= latencyWarningMs
? "#b45309"
: "#15803d";
const latencyLabel = hasLatency
? `${Math.round(latencyMs!)} ms`
: "Unavailable";

return (
<footer
ref={ref}
style={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "12px 16px",
borderTop: `1px solid ${theme.border}`,
backgroundColor: theme.surface,
color: theme.neutralText,
fontSize: 13,
...style,
}}
{...rest}
>
<div>{children}</div>
<div
role="status"
aria-label={`${network} network latency`}
style={{ display: "inline-flex", alignItems: "center", gap: 7 }}
>
<span
aria-hidden="true"
style={{
width: 8,
height: 8,
borderRadius: "50%",
backgroundColor: color,
flexShrink: 0,
}}
/>
<span>{network}</span>
<span style={{ color, fontVariantNumeric: "tabular-nums" }}>
{latencyLabel}
</span>
</div>
</footer>
);
});
Loading
Loading