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
54 changes: 54 additions & 0 deletions docs/CreatorProfileAnchorLinks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Creator Profile Anchor Links

> Issue #159 — Adds URL hash navigation for main creator profile sections.

## Overview

Each creator profile tab (`overview`, `creations`, `collectors`, `activity`) is now addressable via a URL hash fragment. This enables:

- **Direct deep-linking** — share or bookmark a URL like `/#creations` and land on the correct tab.
- **Browser back/forward navigation** — pressing back after switching tabs returns to the previous section.
- **Preserved layout** — the visual appearance and existing component structure are unchanged.

## How it works

### `ProfileTabPillGroup` (`src/components/common/ProfileTabPill.tsx`)

The `enableHashRouting` prop has been activated and the implementation improved:

| Behaviour | Detail |
| ---------------------- | ------------------------------------------------------------------------------------------------ |
| **On mount** | Reads `window.location.hash` and calls `onTabChange` if the hash matches a tab value. |
| **Tab click** | Calls `onTabChange` _and_ sets `window.location.hash` to the tab value. |
| **`hashchange` event** | Listens for browser-level hash changes (e.g. back/forward) and syncs the active tab. |
| **ARIA attributes** | Each `<button>` receives `id="profile-tab-{value}"` and `aria-controls="profile-panel-{value}"`. |

The two previously-duplicated `useEffect` calls are now merged into one to eliminate a potential mount-time race condition.

### `LandingPage` (`src/pages/LandingPage.tsx`)

Three targeted changes:

1. **Initial state** — `activeProfileTab` is initialised from `window.location.hash` so that a page load with a hash (e.g. `/#activity`) shows the correct tab immediately, without waiting for a re-render.
2. **`enableHashRouting`** — passed to `ProfileTabPillGroup`, enabling the hash sync logic.
3. **Panel markup** — the tab-panel `<div>` now carries `id="profile-panel-{activeTab}"`, `role="tabpanel"`, `aria-labelledby="profile-tab-{activeTab}"`, and `tabIndex={0}` so the tab ↔ panel relationship is semantically complete.

## Validation

```bash
# Run the new unit tests
pnpm test ProfileTabPillGroup

# Run the full test suite
pnpm test
```

### Manual verification

1. Open the app at `/`.
2. Navigate to the "Creator profile pattern" section.
3. Click **Creations** — the URL bar should update to `/#creations`.
4. Click **Collectors** — URL updates to `/#collectors`.
5. Press the browser **Back** button — returns to `/#creations` and activates that tab.
6. Open `/#activity` directly — the Activity tab should be active on load.
7. Open `/#` or a non-matching hash — falls back to the Overview tab.
29 changes: 15 additions & 14 deletions src/components/common/ProfileTabPill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,29 @@ export const ProfileTabPillGroup: React.FC<ProfileTabPillGroupProps> = ({
className,
enableHashRouting = false,
}) => {
// Read the initial URL hash on mount and listen for subsequent changes.
// Both concerns share one effect so there is no race between the initial
// read and the listener being attached.
useEffect(() => {
if (!enableHashRouting) return;

const handleHashChange = () => {
const syncFromHash = () => {
const hash = window.location.hash.slice(1);
const validTab = tabs.find(tab => tab.value === hash);
if (validTab && hash !== activeTab) {
onTabChange(hash);
}
};

window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, [enableHashRouting, tabs, activeTab, onTabChange]);
// Sync immediately on mount so direct URL hash navigation works.
syncFromHash();

window.addEventListener('hashchange', syncFromHash);
return () => window.removeEventListener('hashchange', syncFromHash);
// activeTab is intentionally excluded: we only want to sync *from* the
// hash into state, never the other way round inside this effect.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enableHashRouting, tabs, onTabChange]);

const handleTabClick = (value: string) => {
onTabChange(value);
Expand All @@ -99,16 +108,6 @@ export const ProfileTabPillGroup: React.FC<ProfileTabPillGroupProps> = ({
}
};

useEffect(() => {
if (!enableHashRouting) return;

const hash = window.location.hash.slice(1);
const validTab = tabs.find(tab => tab.value === hash);
if (validTab && hash !== activeTab) {
onTabChange(hash);
}
}, [enableHashRouting, tabs, activeTab, onTabChange]);

return (
<nav
role="tablist"
Expand All @@ -118,8 +117,10 @@ export const ProfileTabPillGroup: React.FC<ProfileTabPillGroupProps> = ({
{tabs.map(tab => (
<ProfileTabPill
key={tab.value}
id={`profile-tab-${tab.value}`}
role="tab"
aria-selected={activeTab === tab.value}
aria-controls={`profile-panel-${tab.value}`}
isActive={activeTab === tab.value}
icon={tab.icon}
onClick={() => handleTabClick(tab.value)}
Expand Down
173 changes: 173 additions & 0 deletions src/components/common/__tests__/ProfileTabPillGroup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ProfileTabPillGroup } from '@/components/common/ProfileTabPill';

// ---------------------------------------------------------------------------
// Feature: creator-profile-anchor-links (#159)
// Validates: URL hash navigation, hashchange listener, tab ↔ panel wiring
// ---------------------------------------------------------------------------

const TABS = [
{ label: 'Overview', value: 'overview' },
{ label: 'Creations', value: 'creations' },
{ label: 'Collectors', value: 'collectors' },
{ label: 'Activity', value: 'activity' },
];

describe('ProfileTabPillGroup – hash routing disabled (default)', () => {
beforeEach(() => {
window.location.hash = '';
});

it('does not update the URL hash when a tab is clicked', () => {
const onTabChange = vi.fn();
render(
<ProfileTabPillGroup
tabs={TABS}
activeTab="overview"
onTabChange={onTabChange}
/>
);

fireEvent.click(screen.getByRole('tab', { name: 'Creations' }));

expect(onTabChange).toHaveBeenCalledWith('creations');
expect(window.location.hash).toBe('');
});
});

describe('ProfileTabPillGroup – hash routing enabled', () => {
beforeEach(() => {
// Reset hash before each test
window.location.hash = '';
});

it('sets the URL hash when a tab is clicked', () => {
const onTabChange = vi.fn();
render(
<ProfileTabPillGroup
tabs={TABS}
activeTab="overview"
onTabChange={onTabChange}
enableHashRouting
/>
);

fireEvent.click(screen.getByRole('tab', { name: 'Collectors' }));

expect(onTabChange).toHaveBeenCalledWith('collectors');
expect(window.location.hash).toBe('#collectors');
});

it('reads the initial URL hash and activates the matching tab on mount', () => {
window.location.hash = '#activity';
const onTabChange = vi.fn();

render(
<ProfileTabPillGroup
tabs={TABS}
activeTab="overview"
onTabChange={onTabChange}
enableHashRouting
/>
);

// The component should call onTabChange with the hash value on mount
expect(onTabChange).toHaveBeenCalledWith('activity');
});

it('does not call onTabChange when the hash matches the active tab', () => {
window.location.hash = '#overview';
const onTabChange = vi.fn();

render(
<ProfileTabPillGroup
tabs={TABS}
activeTab="overview"
onTabChange={onTabChange}
enableHashRouting
/>
);

expect(onTabChange).not.toHaveBeenCalled();
});

it('ignores a URL hash that does not match any tab value', () => {
window.location.hash = '#unknown-section';
const onTabChange = vi.fn();

render(
<ProfileTabPillGroup
tabs={TABS}
activeTab="overview"
onTabChange={onTabChange}
enableHashRouting
/>
);

expect(onTabChange).not.toHaveBeenCalled();
});

it('responds to a hashchange event after mount', () => {
const onTabChange = vi.fn();
render(
<ProfileTabPillGroup
tabs={TABS}
activeTab="overview"
onTabChange={onTabChange}
enableHashRouting
/>
);

// Simulate the browser navigating to #creations (e.g. via back/forward)
window.location.hash = '#creations';
fireEvent(window, new HashChangeEvent('hashchange'));

expect(onTabChange).toHaveBeenCalledWith('creations');
});

it('renders each tab with an id and aria-controls pointing to its panel', () => {
const onTabChange = vi.fn();
render(
<ProfileTabPillGroup
tabs={TABS}
activeTab="overview"
onTabChange={onTabChange}
enableHashRouting
/>
);

for (const tab of TABS) {
const button = screen.getByRole('tab', { name: tab.label });
expect(button).toHaveAttribute('id', `profile-tab-${tab.value}`);
expect(button).toHaveAttribute(
'aria-controls',
`profile-panel-${tab.value}`
);
}
});

it('marks only the active tab as selected', () => {
const onTabChange = vi.fn();
render(
<ProfileTabPillGroup
tabs={TABS}
activeTab="creations"
onTabChange={onTabChange}
enableHashRouting
/>
);

expect(screen.getByRole('tab', { name: 'Creations' })).toHaveAttribute(
'aria-selected',
'true'
);

for (const tab of TABS.filter(t => t.value !== 'creations')) {
expect(screen.getByRole('tab', { name: tab.label })).toHaveAttribute(
'aria-selected',
'false'
);
}
});
});
Loading
Loading