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
47 changes: 30 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ A lightweight React library to synchronize a vertical navigation menu with scrol
- [Installation](#-installation)
- [Styles](#-styles)
- [Quick Start](#-quick-start)
- [Usage Patterns](#-usage-patterns)
- [Props Overview](#-props-overview)
- [Customization](#-customization)
- [Docs](#-documentation)
Expand Down Expand Up @@ -76,11 +77,11 @@ Define your sections in an array and let the component generate both nav items a

```tsx
const items = [
{ sectionKey: 'intro', label: 'Introduction', children: <p>...</p> },
{ sectionKey: 'details', label: 'Details', children: <p>...</p> }
{ sectionKey: 'intro', label: 'Introduction', children: <div>...</div> },
{ sectionKey: 'details', label: 'Details', children: <div>...</div> }
];

<DualScrollSync items={items} onItemClick={(k) => console.log(k)} />;
return <DualScrollSync items={items} onItemClick={handleClick} />;
```

### Declarative
Expand All @@ -90,24 +91,36 @@ Write the structure directly in JSX using `DualScrollSync.NavItem` and `DualScro
✅ Best for static pages where you want **full control**.

```tsx
<DualScrollSync>
<DualScrollSync.Nav>
<DualScrollSync.NavItem sectionKey="a">Section A</DualScrollSync.NavItem>
<DualScrollSync.NavItem sectionKey="b">Section B</DualScrollSync.NavItem>
</DualScrollSync.Nav>

<DualScrollSync.Content>
<DualScrollSync.ContentSection sectionKey="a">...</DualScrollSync.ContentSection>
<DualScrollSync.ContentSection sectionKey="b">...</DualScrollSync.ContentSection>
</DualScrollSync.Content>
</DualScrollSync>
return (
<DualScrollSync onItemClick={handleClick}>
<DualScrollSync.Nav>
<DualScrollSync.NavItem sectionKey="intro">Introduction</DualScrollSync.NavItem>
<DualScrollSync.NavItem sectionKey="details">Details</DualScrollSync.NavItem>
</DualScrollSync.Nav>

<DualScrollSync.Content>
<DualScrollSync.ContentSection sectionKey="intro">
<DualScrollSync.Label>Introduction</DualScrollSync.Label>
<div>...</div>
</DualScrollSync.ContentSection>
<DualScrollSync.ContentSection sectionKey="details">
<DualScrollSync.Label>Details</DualScrollSync.Label>
<div>...</div>
</DualScrollSync.ContentSection>
</DualScrollSync.Content>
</DualScrollSync>
);
```

## ⚖️ When to use

✅ Long scrollable pages with sticky nav
✅ Catalog filters, docs sidebars, multi-section layouts
❌ Very short pages (anchors may suffice)
✅ Long scrollable pages with sticky nav

✅ Catalog filters, docs sidebars, multi-section layouts

❌ Very short content (no scrolling needed)

❌ Complex nested navs (not supported)

## 📘 Documentation

Expand Down
5 changes: 4 additions & 1 deletion lib/components/DualScrollSync/DualScrollSync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ export const DualScrollSyncBase: FC<DualScrollSyncProps> = ({
id,
items,
onItemClick,
maxVisibleItems = 6,
style = {}
}) => {
const baseId = id ?? 'dual-scroll-sync';
const baseId = id || 'dual-scroll-sync';
const navId = `${baseId}-nav`;
const contentId = `${baseId}-content`;

Expand All @@ -39,6 +40,7 @@ export const DualScrollSyncBase: FC<DualScrollSyncProps> = ({
sectionRefs,
navItemRefs,
navRef,
maxVisibleItems,
onMenuItemSelect,
onItemClick
}),
Expand All @@ -51,6 +53,7 @@ export const DualScrollSyncBase: FC<DualScrollSyncProps> = ({
sectionRefs,
navItemRefs,
navRef,
maxVisibleItems,
onMenuItemSelect,
onItemClick
]
Expand Down
50 changes: 26 additions & 24 deletions lib/components/DualScrollSync/DualScrollSync.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,30 +18,32 @@ export type DualScrollSyncOptions = {

export type DualScrollSyncItem = PropsWithChildren<DualScrollSyncOptions>;

export type DualScrollSyncProps = PropsWithChildren<DualScrollSyncStyleProps> & {
/**
* Unique identifier for the DualScrollSync component. (Optional)
* @default 'dual-scroll-sync'
*/
id?: string;
/**
* Array of `DualScrollSyncItem` objects.
* If provided, the component will auto-generate the navigation menu and content sections and ignore any children passed directly to it. (Optional)
* @default []
*/
items?: DualScrollSyncItem[];
/**
* Maximum visible items in the navigation menu. If the number of items exceeds this value, scrolling is activated. (Optional)
* @default 6
*/
maxVisibleItems?: number;
/**
* Callback function triggered when active section changes.
* @param activeKey - The key of the active section.
* @default () => {}
*/
onItemClick?: (activeKey: string) => void;
};
export type DualScrollSyncProps = PropsWithChildren<
DualScrollSyncStyleProps & {
/**
* Unique identifier for the DualScrollSync component. (Optional)
* @default 'dual-scroll-sync'
*/
id?: string;
/**
* Array of `DualScrollSyncItem` objects.
* If provided, the component will auto-generate the navigation menu and content sections and ignore any children passed directly to it. (Optional)
* @default []
*/
items?: DualScrollSyncItem[];
/**
* Maximum visible items in the navigation menu. If the number of items exceeds this value, scrolling is activated. (Optional)
* @default 6
*/
maxVisibleItems?: number;
/**
* Callback function triggered when active section changes.
* @param activeKey - The key of the active section.
* @default () => {}
*/
onItemClick?: (activeKey: string) => void;
}
>;

export type DualScrollSyncType = FC<DualScrollSyncProps> & {
Nav: FC<DualScrollSyncNavProps>;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { DualScrollSyncItem, DualScrollSyncStyleProps } from '../DualScrollSync.types';
import type { PropsWithChildren } from 'react';

export type DualScrollSyncContentSectionProps = Omit<DualScrollSyncItem, 'label'> &
DualScrollSyncStyleProps;
import type { DualScrollSyncOptions, DualScrollSyncStyleProps } from '../DualScrollSync.types';

export type DualScrollSyncContentSectionProps = PropsWithChildren<
DualScrollSyncStyleProps & Pick<DualScrollSyncOptions, 'sectionKey'>
>;
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { render } from '@testing-library/react';
import { vi } from 'vitest';

import { mockScrollSyncContextProps, spyUseDualScrollSyncContext } from '@/setupTests';

import { DualScrollSyncNav } from './DualScrollSyncNav';

describe('DualScrollSyncNav', () => {
Expand All @@ -19,8 +21,13 @@ describe('DualScrollSyncNav', () => {
});

it('should apply maxVisibleItems correctly', () => {
spyUseDualScrollSyncContext.mockReturnValueOnce({
...mockScrollSyncContextProps,
maxVisibleItems: 3
});

const { getByTestId } = render(
<DualScrollSyncNav maxVisibleItems={3}>
<DualScrollSyncNav>
<div>Item 1</div>
<div>Item 2</div>
<div>Item 3</div>
Expand All @@ -34,7 +41,7 @@ describe('DualScrollSyncNav', () => {

it('should limit visible items to the number of children if fewer than maxVisibleItems', () => {
const { getByTestId } = render(
<DualScrollSyncNav maxVisibleItems={5}>
<DualScrollSyncNav>
<div>Item 1</div>
<div>Item 2</div>
<div>Item 3</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@ import type { DualScrollSyncNavProps } from './DualScrollSyncNav.types';
export const DualScrollSyncNav: FC<DualScrollSyncNavProps> = ({
children,
className,
maxVisibleItems = 6,
style = {}
}) => {
const { navId, navRef } = useDualScrollSyncContext();
const { navId, navRef, maxVisibleItems } = useDualScrollSyncContext();
const navItemCount = Children.count(children);
const visibleItemsCount = Math.min(maxVisibleItems, navItemCount);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,4 @@ import type { PropsWithChildren } from 'react';

import type { DualScrollSyncStyleProps } from '../DualScrollSync.types';

export type DualScrollSyncNavProps = DualScrollSyncStyleProps &
PropsWithChildren<{ maxVisibleItems?: number }>;
export type DualScrollSyncNavProps = PropsWithChildren<DualScrollSyncStyleProps>;
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ import type { PropsWithChildren } from 'react';

import type { DualScrollSyncOptions, DualScrollSyncStyleProps } from '../DualScrollSync.types';

export type DualScrollSyncNavItemProps = Pick<DualScrollSyncOptions, 'sectionKey'> &
PropsWithChildren<DualScrollSyncStyleProps>;
export type DualScrollSyncNavItemProps = PropsWithChildren<
DualScrollSyncStyleProps & Pick<DualScrollSyncOptions, 'sectionKey'>
>;
1 change: 1 addition & 0 deletions lib/contexts/DualScrollSyncContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface DualScrollSyncContextProps extends UseScrollSyncObserverReturn
baseId: string;
navId: string;
contentId: string;
maxVisibleItems: number;
onItemClick?: (activeKey: string) => void;
}

Expand Down
4 changes: 3 additions & 1 deletion lib/hooks/useDualScrollSyncContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ const mockValue = {
navItemRefs: { current: {} },
navRef: { current: null },
sectionRefs: { current: {} },
onMenuItemSelect: vi.fn()
onMenuItemSelect: vi.fn(),
onItemClick: vi.fn(),
maxVisibleItems: 6
};

const Wrapper: FC<PropsWithChildren> = ({ children }) => {
Expand Down
11 changes: 9 additions & 2 deletions lib/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { afterEach, vi } from 'vitest';

import * as hooks from '@/hooks';

import type { DualScrollSyncContextProps } from './contexts';

beforeEach(() => {
expect.hasAssertions();
});
Expand Down Expand Up @@ -57,7 +59,7 @@ vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
export const mockOnItemClick = vi.fn();
export const mockOnMenuItemSelect = vi.fn();

vi.spyOn(hooks, 'useDualScrollSyncContext').mockReturnValue({
export const mockScrollSyncContextProps: DualScrollSyncContextProps = {
contentId: 'test-content-id',
navId: 'test-nav-id',
baseId: 'test',
Expand All @@ -66,6 +68,11 @@ vi.spyOn(hooks, 'useDualScrollSyncContext').mockReturnValue({
navItemRefs: { current: {} },
navRef: { current: null },
sectionRefs: { current: {} },
maxVisibleItems: 6,
onMenuItemSelect: mockOnMenuItemSelect,
onItemClick: mockOnItemClick
});
};

export const spyUseDualScrollSyncContext = vi.spyOn(hooks, 'useDualScrollSyncContext');

spyUseDualScrollSyncContext.mockReturnValue(mockScrollSyncContextProps);