From e3dbc8f4ed5c535410de410b7611a56e15aa9698 Mon Sep 17 00:00:00 2001 From: Shifau Balogun Date: Thu, 15 Oct 2026 19:17:56 +0000 Subject: [PATCH] feat(react): add navigation and control components --- react/src/components/Breadcrumbs.tsx | 74 ++++++++++ react/src/components/Button.tsx | 123 +++++++++++++++++ react/src/components/COMPONENTS.md | 48 +++++++ react/src/components/Footer.tsx | 80 +++++++++++ .../components/NavigationComponents.test.tsx | 84 ++++++++++++ react/src/components/SegmentedControl.tsx | 126 +++++++++++++++++ react/src/components/Tabs.tsx | 129 ++++++++++++++++++ react/src/components/index.ts | 20 +++ 8 files changed, 684 insertions(+) create mode 100644 react/src/components/Breadcrumbs.tsx create mode 100644 react/src/components/Button.tsx create mode 100644 react/src/components/Footer.tsx create mode 100644 react/src/components/NavigationComponents.test.tsx create mode 100644 react/src/components/SegmentedControl.tsx create mode 100644 react/src/components/Tabs.tsx diff --git a/react/src/components/Breadcrumbs.tsx b/react/src/components/Breadcrumbs.tsx new file mode 100644 index 00000000..31490aba --- /dev/null +++ b/react/src/components/Breadcrumbs.tsx @@ -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 { + items: BreadcrumbItem[]; + ariaLabel?: string; + separator?: React.ReactNode; +} + +export const Breadcrumbs = forwardRef( + function Breadcrumbs( + { items, ariaLabel = "Breadcrumb", separator = "/", style, ...rest }, + ref, + ) { + return ( + + ); + }, +); diff --git a/react/src/components/Button.tsx b/react/src/components/Button.tsx new file mode 100644 index 00000000..70b75300 --- /dev/null +++ b/react/src/components/Button.tsx @@ -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 { + variant?: ButtonVariant; + size?: ButtonSize; + loading?: boolean; + loadingText?: string; +} + +const VARIANTS: Record = { + 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 = { + 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( + function Button( + { + variant = "primary", + size = "md", + loading = false, + loadingText, + disabled, + style, + children, + ...rest + }, + ref, + ) { + return ( + + ); + }, +); diff --git a/react/src/components/COMPONENTS.md b/react/src/components/COMPONENTS.md index ed85bdd4..544f6c9e 100644 --- a/react/src/components/COMPONENTS.md +++ b/react/src/components/COMPONENTS.md @@ -57,6 +57,54 @@ const [open, setOpen] = useState(false); 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 + +``` + +## 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 + +``` + +## 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 +
Build 42
+``` + +## 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 + }, + { label: 'Activity', value: 'activity', content: }, +]} /> + +``` ## Badge Small label used for statuses, counts, or categories. When `onClick` is diff --git a/react/src/components/Footer.tsx b/react/src/components/Footer.tsx new file mode 100644 index 00000000..b4f68d39 --- /dev/null +++ b/react/src/components/Footer.tsx @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +import React, { forwardRef } from "react"; +import { theme } from "../theme"; + +export interface FooterProps extends React.HTMLAttributes { + network?: string; + latencyMs?: number | null; + latencyWarningMs?: number; + latencyErrorMs?: number; +} + +export const Footer = forwardRef(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 ( +
+
{children}
+
+
+
+ ); +}); diff --git a/react/src/components/NavigationComponents.test.tsx b/react/src/components/NavigationComponents.test.tsx new file mode 100644 index 00000000..50ce9fd0 --- /dev/null +++ b/react/src/components/NavigationComponents.test.tsx @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +import "@testing-library/jest-dom"; +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { Breadcrumbs } from "./Breadcrumbs"; +import { Button } from "./Button"; +import { Footer } from "./Footer"; +import { SegmentedControl } from "./SegmentedControl"; +import { Tabs } from "./Tabs"; + +describe("navigation and control components", () => { + it("renders linked breadcrumbs and marks the current page", () => { + render( + , + ); + expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute( + "href", + "/", + ); + expect(screen.getByText("Vaults")).toHaveAttribute("aria-current", "page"); + }); + + it("renders network latency with threshold status", () => { + render( +
+ Build 42 +
, + ); + expect(screen.getByText("Build 42")).toBeInTheDocument(); + expect( + screen.getByRole("status", { name: "Testnet network latency" }), + ).toHaveTextContent("420 ms"); + }); + + it("disables a loading button and exposes its busy state", () => { + render( + , + ); + expect(screen.getByRole("button", { name: "Saving..." })).toBeDisabled(); + expect(screen.getByRole("button")).toHaveAttribute("aria-busy", "true"); + }); + + it("changes tabs by click and keyboard navigation", () => { + render( + , + ); + const overview = screen.getByRole("tab", { name: "Overview" }); + fireEvent.keyDown(overview, { key: "ArrowRight" }); + expect(screen.getByRole("tab", { name: "Activity" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.getByRole("tabpanel")).toHaveTextContent("Activity panel"); + }); + + it("updates the selected segmented option", () => { + const onChange = jest.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("radio", { name: "Grid" })); + expect(screen.getByRole("radio", { name: "Grid" })).toHaveAttribute( + "aria-checked", + "true", + ); + expect(onChange).toHaveBeenCalledWith("grid"); + }); +}); diff --git a/react/src/components/SegmentedControl.tsx b/react/src/components/SegmentedControl.tsx new file mode 100644 index 00000000..2e865e69 --- /dev/null +++ b/react/src/components/SegmentedControl.tsx @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT +import React, { forwardRef, useState } from "react"; +import { theme } from "../theme"; + +export interface SegmentedOption { + label: React.ReactNode; + value: string; + disabled?: boolean; +} + +export interface SegmentedControlProps extends Omit< + React.HTMLAttributes, + "onChange" +> { + options: SegmentedOption[]; + value?: string; + defaultValue?: string; + onChange?: (value: string) => void; + ariaLabel: string; + disabled?: boolean; +} + +export const SegmentedControl = forwardRef< + HTMLDivElement, + SegmentedControlProps +>(function SegmentedControl( + { + options, + value, + defaultValue, + onChange, + ariaLabel, + disabled = false, + style, + ...rest + }, + ref, +) { + const [internalValue, setInternalValue] = useState( + defaultValue ?? options.find((option) => !option.disabled)?.value ?? "", + ); + const selectedValue = value ?? internalValue; + + function select(option: SegmentedOption) { + if (disabled || option.disabled) return; + if (value === undefined) setInternalValue(option.value); + onChange?.(option.value); + } + + function handleKeyDown( + event: React.KeyboardEvent, + index: number, + ) { + if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return; + event.preventDefault(); + const enabled = options + .map((option, optionIndex) => + !option.disabled && !disabled ? optionIndex : -1, + ) + .filter((optionIndex) => optionIndex >= 0); + const current = enabled.indexOf(index); + if (!enabled.length || current < 0) return; + const direction = event.key === "ArrowRight" ? 1 : -1; + const target = + enabled[(current + direction + enabled.length) % enabled.length]; + if (target === undefined) return; + const targetOption = options[target]; + if (targetOption) select(targetOption); + const buttons = + event.currentTarget.parentElement?.querySelectorAll( + '[role="radio"]', + ); + buttons?.[target]?.focus(); + } + + return ( +
+ {options.map((option) => { + const selected = option.value === selectedValue; + const isDisabled = disabled || !!option.disabled; + return ( + + ); + })} +
+ ); +}); diff --git a/react/src/components/Tabs.tsx b/react/src/components/Tabs.tsx new file mode 100644 index 00000000..080cfd7c --- /dev/null +++ b/react/src/components/Tabs.tsx @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +import React, { forwardRef, useId, useState } from "react"; +import { theme } from "../theme"; + +export interface TabItem { + label: React.ReactNode; + value: string; + content: React.ReactNode; + disabled?: boolean; +} + +export interface TabsProps extends Omit< + React.HTMLAttributes, + "onChange" +> { + items: TabItem[]; + value?: string; + defaultValue?: string; + onChange?: (value: string) => void; + ariaLabel?: string; +} + +export const Tabs = forwardRef(function Tabs( + { items, value, defaultValue, onChange, ariaLabel = "Tabs", style, ...rest }, + ref, +) { + const id = useId(); + const [internalValue, setInternalValue] = useState( + defaultValue ?? items.find((item) => !item.disabled)?.value ?? "", + ); + const activeValue = value ?? internalValue; + const activeIndex = items.findIndex( + (item) => item.value === activeValue && !item.disabled, + ); + const activeItem = activeIndex >= 0 ? items[activeIndex] : undefined; + + function select(item: TabItem) { + if (item.disabled) return; + if (value === undefined) setInternalValue(item.value); + onChange?.(item.value); + } + + function handleKeyDown( + event: React.KeyboardEvent, + index: number, + ) { + const direction = + event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; + if (event.key === "Home" || event.key === "End" || direction !== 0) { + event.preventDefault(); + const available = items + .map((item, itemIndex) => (!item.disabled ? itemIndex : -1)) + .filter((itemIndex) => itemIndex >= 0); + if (!available.length) return; + const target = + event.key === "Home" + ? available[0] + : event.key === "End" + ? available[available.length - 1] + : available[ + (available.indexOf(index) + direction + available.length) % + available.length + ]; + if (target === undefined) return; + const button = document.getElementById(`${id}-tab-${target}`); + button?.focus(); + const targetItem = items[target]; + if (targetItem) select(targetItem); + } + } + + return ( +
+
+ {items.map((item, index) => { + const selected = item.value === activeItem?.value; + return ( + + ); + })} +
+ {activeItem ? ( +
+ {activeItem.content} +
+ ) : null} +
+ ); +}); diff --git a/react/src/components/index.ts b/react/src/components/index.ts index d7fbd676..f913c578 100644 --- a/react/src/components/index.ts +++ b/react/src/components/index.ts @@ -5,6 +5,26 @@ export type { AlertProps, AlertVariant } from "./Alert"; export { Badge } from "./Badge"; export type { BadgeProps, BadgeVariant, BadgeSize } from "./Badge"; +export { Breadcrumbs } from "./Breadcrumbs"; +export type { BreadcrumbItem, BreadcrumbsProps } from "./Breadcrumbs"; + +export { Button } from "./Button"; +export type { ButtonProps, ButtonVariant, ButtonSize } from "./Button"; + +export { Footer } from "./Footer"; +export type { FooterProps } from "./Footer"; + +export { SegmentedControl } from "./SegmentedControl"; +export type { + SegmentedControlProps, + SegmentedOption, +} from "./SegmentedControl"; + +export { Tabs } from "./Tabs"; +export type { TabsProps, TabItem } from "./Tabs"; + +export { Modal } from "./Modal"; +export type { ModalProps, ModalSize } from "./Modal"; export { ConfirmationDialog } from "./ConfirmationDialog"; export type { ConfirmationDialogProps,