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
24 changes: 24 additions & 0 deletions .storybook/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,30 @@ export const mockConfig: Config = {
},
},
},
{
name: 'glamsterdam-devnet-7',
display_name: 'Glamsterdam-devnet-7',
chain_id: 7091047533,
genesis_time: 1784808000,
genesis_delay: 60,
forks: {
consensus: {
gloas: { epoch: 0, min_client_versions: {} },
},
},
},
{
name: 'glamsterdam-devnet-8',
display_name: 'Glamsterdam-devnet-8',
chain_id: 7091047534,
genesis_time: 1786622400,
genesis_delay: 60,
forks: {
consensus: {
gloas: { epoch: 0, min_client_versions: {} },
},
},
},
],
features: [],
};
Expand Down
3 changes: 2 additions & 1 deletion src/components/DataDisplay/Timestamp/TimestampModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { slotToEpoch, epochToTimestamp, SECONDS_PER_SLOT, SLOTS_PER_EPOCH } from
import { getAllDiscordFormats, DISCORD_STYLE_LABELS } from '@/utils/discord-timestamp';
import { NetworkIcon } from '@/components/Ethereum/NetworkIcon';
import { formatSlot, formatEpoch } from '@/utils/number';
import { getNetworkFullLabel } from '@/utils/networks';
import { Slot } from '@/components/Ethereum/Slot';
import { Epoch } from '@/components/Ethereum/Epoch';
import type { DiscordTimestampStyle } from '@/utils/discord-timestamp';
Expand Down Expand Up @@ -179,7 +180,7 @@ function TimestampModalContent({ timestamp }: TimestampModalContentProps): JSX.E
<TabPanel>
<div className="mb-3 flex items-center gap-2 text-sm/6 text-muted">
<NetworkIcon networkName={currentNetwork.name} className="size-5" />
<span className="font-semibold text-foreground">{currentNetwork.display_name}</span>
<span className="font-semibold text-foreground">{getNetworkFullLabel(currentNetwork)}</span>
</div>
<div className="overflow-hidden rounded-sm border border-border">
<table className="w-full">
Expand Down
16 changes: 16 additions & 0 deletions src/components/Ethereum/NetworkSelect/NetworkSelect.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,22 @@ export const WithHoleskySelected: Story = {
},
};

/**
* Network selector with a devnet pre-selected. The button shows the devnet family above the
* devnet label, and the menu lists devnets under their family heading.
*/
export const WithDevnetSelected: Story = {
args: {
showLabel: false,
},
parameters: {
router: {
initialUrl: '/',
initialSearch: { network: 'glamsterdam-devnet-8' },
},
},
};

/**
* Network selector with Sepolia pre-selected via URL params.
*/
Expand Down
134 changes: 80 additions & 54 deletions src/components/Ethereum/NetworkSelect/NetworkSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,24 @@
import { type JSX, useMemo } from 'react';
import { useNetwork, type Network } from '@/hooks/useNetwork';
import { SelectMenu, type SelectMenuOption } from '@/components/Forms/SelectMenu';
import { clsx } from 'clsx';
import { Label, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/16/solid';
import { useNetwork } from '@/hooks/useNetwork';
import { NetworkIcon } from '@/components/Ethereum/NetworkIcon';
import { NETWORK_ORDER } from '@/utils/networks';
import { getNetworkFullLabel, getNetworkShortLabel, groupNetworks, parseNetworkName } from '@/utils/networks';
import type { NetworkSelectProps } from './NetworkSelect.types';

/**
* Sorts networks by the predefined order, then alphabetically for any remaining networks.
*/
function sortNetworks(networks: Network[]): Network[] {
return [...networks].sort((a, b) => {
const aIndex = NETWORK_ORDER.indexOf(a.name);
const bIndex = NETWORK_ORDER.indexOf(b.name);

// Both in the priority list - sort by their position
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex;
}

// Only a is in the priority list - a comes first
if (aIndex !== -1) {
return -1;
}

// Only b is in the priority list - b comes first
if (bIndex !== -1) {
return 1;
}

// Neither in priority list - sort alphabetically by name
return a.name.localeCompare(b.name);
});
}

/**
* Network selector dropdown component.
*
* Displays a list of available networks from the config and allows switching between them.
* The selected network is persisted in localStorage and accessible via the useNetwork hook.
* Displays the available networks from the config, grouped into sections, and allows switching
* between them. The selected network is persisted in localStorage and accessible via the
* useNetwork hook.
*
* Features:
* - Defaults to "mainnet" if available, otherwise the first network
* - Shows network display name with NetworkIcon component
* - Public networks first (mainnet, holesky, sepolia, hoodi), then one section per devnet family
* - Devnets are labelled within their family ("Devnet 8" under "Glamsterdam") so long names never truncate
* - The selected devnet shows its family above its label in the button
* - Highlights currently selected network with a checkmark
* - Networks ordered by priority: mainnet, holesky, sepolia, hoodi, then alphabetically
* - Keyboard accessible
* - Dark mode support
*
Expand All @@ -66,29 +41,80 @@ export function NetworkSelect({
}: NetworkSelectProps): JSX.Element {
const { currentNetwork, setCurrentNetwork, networks } = useNetwork();

const options: SelectMenuOption<Network>[] = useMemo(
() =>
sortNetworks(networks).map((network: Network) => ({
value: network,
label: network.display_name,
icon: <NetworkIcon networkName={network.name} />,
})),
[networks]
);
const groups = useMemo(() => groupNetworks(networks), [networks]);

if (!currentNetwork) {
return <></>;
}

const family = parseNetworkName(currentNetwork.name)?.family;

return (
<SelectMenu
value={currentNetwork}
onChange={setCurrentNetwork}
options={options}
showLabel={showLabel}
label={label}
placeholder="Select network"
expandToFit={expandToFit}
/>
<Listbox value={currentNetwork} onChange={setCurrentNetwork} by="name">
{showLabel && <Label className="block text-sm/6 font-medium text-foreground">{label}</Label>}
<div className={clsx('relative', showLabel && 'mt-2')}>
<ListboxButton
title={getNetworkFullLabel(currentNetwork)}
className={clsx(
'relative flex h-11 cursor-pointer items-center gap-2 border border-border bg-surface/50 pr-10 pl-3 text-left shadow-xs backdrop-blur-sm transition-all hover:border-primary/30 hover:bg-surface/70 hover:shadow-sm',
expandToFit ? 'w-auto min-w-[140px]' : 'w-full'
)}
>
<span className="flex size-5 shrink-0 items-center justify-center">
<NetworkIcon networkName={currentNetwork.name} />
</span>
<span className="flex min-w-0 flex-col">
{family && (
<span className="truncate text-[10px]/3 font-semibold tracking-wider text-muted uppercase">{family}</span>
)}
<span className="truncate text-base/5 text-foreground">{getNetworkShortLabel(currentNetwork)}</span>
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon aria-hidden="true" className="size-5 text-muted" />
</span>
</ListboxButton>

<ListboxOptions
anchor="bottom start"
transition
className={clsx(
'z-[9999] mt-1 max-h-96 overflow-auto border border-border bg-surface pb-1 shadow-lg backdrop-blur-xl focus:outline-hidden data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0',
expandToFit ? 'w-max min-w-[var(--button-width)]' : 'w-[var(--button-width)]'
)}
>
{groups.map((group, index) => (
<div
key={group.id}
role="group"
aria-label={group.label}
className={clsx(index > 0 && 'mt-1 border-t border-border/50')}
>
<div
aria-hidden="true"
className="px-3 pt-2.5 pb-1 text-[10px]/3 font-semibold tracking-wider text-muted/60 uppercase"
>
{group.label}
</div>
{group.networks.map(network => (
<ListboxOption
key={network.name}
value={network}
title={getNetworkFullLabel(network)}
className="group relative flex cursor-pointer items-center gap-2 py-2 pr-9 pl-3 text-foreground transition-colors select-none data-focus:bg-primary/10 data-focus:text-primary data-selected:text-primary"
>
<span className="flex size-5 shrink-0 items-center justify-center">
<NetworkIcon networkName={network.name} />
</span>
<span className="truncate">{getNetworkShortLabel(network)}</span>
<span className="absolute inset-y-0 right-0 hidden items-center pr-3 group-data-selected:flex">
<CheckIcon aria-hidden="true" className="size-4" />
</span>
</ListboxOption>
))}
</div>
))}
</ListboxOptions>
</div>
</Listbox>
);
}
3 changes: 2 additions & 1 deletion src/components/Layout/PopoutCard/PopoutCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { CopyToClipboard } from '@/components/Elements/CopyToClipboard';
import { NetworkIcon } from '@/components/Ethereum/NetworkIcon';
import { useChartDownload } from '@/hooks/useChartDownload';
import { useNetwork } from '@/hooks/useNetwork';
import { getNetworkFullLabel } from '@/utils/networks';
import type { PopoutCardProps } from './PopoutCard.types';

/**
Expand Down Expand Up @@ -123,7 +124,7 @@ export function PopoutCard({
// Metadata footer for downloads (only shown during screenshot capture)
const metadataTitle = downloadMetadataTitle || document.title;
const metadataSubtitle = downloadMetadataSubtitle || window.location.href;
const metadataNetwork = downloadMetadataNetwork || currentNetwork?.display_name || '';
const metadataNetwork = downloadMetadataNetwork || (currentNetwork ? getNetworkFullLabel(currentNetwork) : '');
const metadataFooter =
appendMetadataToDownload && showMetadata ? (
<div
Expand Down
3 changes: 2 additions & 1 deletion src/components/Layout/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { GitHubIcon } from '@/components/Elements/Icons';
import { ThemeToggle } from '@/components/Layout/ThemeToggle';
import { useConfig } from '@/hooks/useConfig';
import { useNetwork } from '@/hooks/useNetwork';
import { getNetworkFullLabel } from '@/utils/networks';
import type { SidebarProps } from './Sidebar.types';

/**
Expand Down Expand Up @@ -428,7 +429,7 @@ export function Sidebar({ sidebarOpen, setSidebarOpen, collapsed, setCollapsed }
{/* Network Selector */}
{collapsed ? (
currentNetwork && (
<div className="flex justify-center" title={currentNetwork.display_name}>
<div className="flex justify-center" title={getNetworkFullLabel(currentNetwork)}>
<NetworkIcon networkName={currentNetwork.name} className="size-6" />
</div>
)
Expand Down
8 changes: 6 additions & 2 deletions src/components/Overlays/FeatureGate/FeatureGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useLocation, Link } from '@tanstack/react-router';
import { useIsPageEnabled } from '@/hooks/useIsPageEnabled';
import { useNetwork } from '@/hooks/useNetwork';
import { useConfig } from '@/hooks/useConfig';
import { getNetworkFullLabel } from '@/utils/networks';

interface FeatureGateProps {
children: React.ReactNode;
Expand Down Expand Up @@ -47,7 +48,10 @@ export function FeatureGate({ children }: FeatureGateProps): JSX.Element {
<h1 className="text-2xl font-bold text-foreground">Feature Not Available</h1>
<p className="mt-4 max-w-md text-center text-muted">
This feature is not available on{' '}
<span className="font-semibold text-foreground">{currentNetwork?.display_name}</span>.
<span className="font-semibold text-foreground">
{currentNetwork && getNetworkFullLabel(currentNetwork)}
</span>
.
</p>

{availableNetworks.length > 0 && (
Expand All @@ -61,7 +65,7 @@ export function FeatureGate({ children }: FeatureGateProps): JSX.Element {
search={{ network: network.name }}
className="font-medium text-primary hover:underline"
>
{network.display_name}
{getNetworkFullLabel(network)}
</Link>
</Fragment>
))}
Expand Down
41 changes: 15 additions & 26 deletions src/pages/home/components/HeroSection/HeroSection.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { type JSX, useEffect, useMemo, useRef, useState } from 'react';
import { type JSX, Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from '@tanstack/react-router';
import { NetworkIcon } from '@/components/Ethereum/NetworkIcon';
import { type Network } from '@/hooks/useConfig/useConfig.types';
import { useNetwork } from '@/hooks/useNetwork';
import { SECONDS_PER_SLOT, SLOTS_PER_EPOCH } from '@/utils/beacon';
import { getActiveFork } from '@/utils/forks';
import { NETWORK_ORDER } from '@/utils/networks';
import { getNetworkShortLabel, groupNetworks } from '@/utils/networks';
import { formatSlot } from '@/utils/number';

/** Compute the current slot for a network from its genesis time. */
Expand Down Expand Up @@ -88,7 +88,7 @@ function NetworkCard({
<div className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-success transition-shadow duration-200 group-hover:shadow-[0_0_6px_var(--color-success)]" />
<span className="text-xs/4 font-semibold tracking-tight text-foreground sm:text-sm/4">
{network.display_name}
{getNetworkShortLabel(network)}
</span>
</div>
<span className="text-[10px]/3 font-medium tracking-wide text-muted uppercase">{forkLabel}</span>
Expand Down Expand Up @@ -117,13 +117,7 @@ export function HeroSection(): JSX.Element {
const { networks, setCurrentNetwork } = useNetwork();
const now = useNow();

const { staticNets, devnets } = useMemo(() => {
const s = networks
.filter(n => NETWORK_ORDER.includes(n.name))
.sort((a, b) => NETWORK_ORDER.indexOf(a.name) - NETWORK_ORDER.indexOf(b.name));
const d = networks.filter(n => !NETWORK_ORDER.includes(n.name));
return { staticNets: s, devnets: d };
}, [networks]);
const groups = useMemo(() => groupNetworks(networks), [networks]);

return (
<div className="relative flex min-h-dvh flex-col justify-start lg:justify-center lg:overflow-hidden">
Expand Down Expand Up @@ -157,27 +151,22 @@ export function HeroSection(): JSX.Element {
</div>

{/* Network instrument panels */}
{(staticNets.length > 0 || devnets.length > 0) && (
{groups.length > 0 && (
<div className="mx-auto mt-6 flex max-w-4xl animate-fade-in-delay-2 flex-col items-center gap-3 sm:mt-8 lg:mt-12">
{staticNets.length > 0 && (
<div className="flex flex-wrap justify-center gap-3">
{staticNets.map(network => (
<NetworkCard key={network.name} network={network} now={now} onSelectNetwork={setCurrentNetwork} />
))}
</div>
)}
{devnets.length > 0 && (
<>
<span className="mt-1 text-[10px]/3 font-medium tracking-[0.15em] text-muted/40 uppercase">
Devnets
</span>
{groups.map(group => (
<Fragment key={group.id}>
{group.id !== 'public' && (
<span className="mt-1 text-[10px]/3 font-medium tracking-[0.15em] text-muted/40 uppercase">
{group.id === 'other' ? 'Devnets' : `${group.label} devnets`}
</span>
)}
<div className="flex flex-wrap justify-center gap-3">
{devnets.map(network => (
{group.networks.map(network => (
<NetworkCard key={network.name} network={network} now={now} onSelectNetwork={setCurrentNetwork} />
))}
</div>
</>
)}
</Fragment>
))}
</div>
)}
</div>
Expand Down
Loading
Loading