Skip to content

Commit

Permalink
Add collapse functionality to Scroll component with context support (#43
Browse files Browse the repository at this point in the history
)
  • Loading branch information
galangel authored Jan 24, 2025
1 parent 5e186d6 commit 541302a
Show file tree
Hide file tree
Showing 7 changed files with 191 additions and 51 deletions.
24 changes: 24 additions & 0 deletions src/components/Scroll/Scroll.provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export type ScrollContextType = {
setListRef: (listRef: HTMLUListElement) => void;
headerBehavior: HeaderBehavior;
addHeader: (header: HTMLLIElement, path: number[]) => void;
headerCollaspeOpen: (path: number[]) => void;
headerCollaspeClose: (path: number[]) => void;
collapsedPaths: string[];
};

const HeadersContext = React.createContext<ScrollContextType>({
Expand All @@ -27,6 +30,9 @@ const HeadersContext = React.createContext<ScrollContextType>({
scrollBehavior: 'smooth',
headerBehavior: 'none',
addHeader: () => {},
headerCollaspeOpen: () => {},
headerCollaspeClose: () => {},
collapsedPaths: [],
});

export const useScrollContext = () => React.useContext(HeadersContext);
Expand All @@ -44,6 +50,7 @@ export const HeadersProvider: React.FC<IHeadersProvider> = ({
headerBehavior = 'none',
}) => {
const [listRef, setListRef] = useState<HTMLUListElement | null>(null);
const [collapsedPaths, setCollapsedPaths] = useState<string[]>([]);

const headers = useRef<{ [key: string]: HTMLLIElement }>({});

Expand Down Expand Up @@ -130,6 +137,20 @@ export const HeadersProvider: React.FC<IHeadersProvider> = ({
return size;
};

const headerCollaspeOpen = (path: number[]): undefined => {
const pathString = path.join('-');
if (collapsedPaths.includes(pathString)) {
setCollapsedPaths((prev) => prev.filter((item) => item !== pathString));
}
};

const headerCollaspeClose = (path: number[]): undefined => {
const pathString = path.join('-');
if (!collapsedPaths.includes(pathString)) {
setCollapsedPaths((prev) => [...prev, pathString]);
}
};

return (
<HeadersContext.Provider
value={{
Expand All @@ -141,6 +162,9 @@ export const HeadersProvider: React.FC<IHeadersProvider> = ({
setListRef,
scrollBehavior,
headerBehavior,
headerCollaspeOpen,
headerCollaspeClose,
collapsedPaths,
}}
>
{children}
Expand Down
84 changes: 83 additions & 1 deletion src/components/Scroll/Scroll.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,40 @@ const Header =
);
};

const HeaderCollapse =
(key: number): Item['render'] =>
({ collapse }) => {
const { isOpen, close, open } = collapse ?? {};
return (
<div
style={{
fontFamily: 'monospace',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
height: `20px`,
padding: '5px',
backgroundColor: `#${((key * 1234567) & 0xffffff).toString(16).padStart(6, '0')}`,
width: '100%',
justifyContent: 'space-between',
}}
>
<span>Header</span>
<button
onClick={isOpen ? close : open}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '12px',
}}
>
{isOpen ? '▼' : '▶'}
</button>
</div>
);
};

const item: Item['render'] = () => {
return (
<div
Expand All @@ -80,19 +114,30 @@ const item: Item['render'] = () => {
</div>
);
};

const createItems = (headerCount: number, itemCount: number, level: number = 1): Items => {
if (headerCount === 0 || level === 0) {
return Array.from({ length: itemCount }, () => ({ render: item }));
}

return Array.from({ length: headerCount }, () => ({
render: Header(level),
render: HeaderCollapse(level),
nestedItems: createItems(headerCount, itemCount, level - 1),
}));
};

export const BasicExample: Story = {
render: (args) => {
const createItems = (headerCount: number, itemCount: number, level: number = 1): Items => {
if (headerCount === 0 || level === 0) {
return Array.from({ length: itemCount }, () => ({ render: item }));
}

return Array.from({ length: headerCount }, () => ({
render: Header(level),
nestedItems: createItems(headerCount, itemCount, level - 1),
}));
};
const myitems: Items = [...createItems(1, 7), ...createItems(5, 3), ...createItems(1, 7)];
return (
<div style={{ width: '400px', height: '400px' }}>
Expand All @@ -106,8 +151,19 @@ export const BasicExample: Story = {
headerBehavior: 'none',
},
};

export const NestedExample: Story = {
render: (args) => {
const createItems = (headerCount: number, itemCount: number, level: number = 1): Items => {
if (headerCount === 0 || level === 0) {
return Array.from({ length: itemCount }, () => ({ render: item }));
}

return Array.from({ length: headerCount }, () => ({
render: Header(level),
nestedItems: createItems(headerCount, itemCount, level - 1),
}));
};
const myitems: Items = [...createItems(3, 10, 3)];
return (
<div style={{ width: '400px', height: '500px' }}>
Expand All @@ -121,3 +177,29 @@ export const NestedExample: Story = {
headerBehavior: 'none',
},
};

export const CollapseExample: Story = {
render: (args) => {
const createItems = (headerCount: number, itemCount: number, level: number = 1): Items => {
if (headerCount === 0 || level === 0) {
return Array.from({ length: itemCount }, () => ({ render: item }));
}

return Array.from({ length: headerCount }, () => ({
render: HeaderCollapse(level),
nestedItems: createItems(headerCount, itemCount, level - 1),
}));
};
const myitems: Items = [...createItems(1, 10), ...createItems(1, 10), ...createItems(1, 10)];
return (
<div style={{ width: '400px', height: '400px' }}>
<Scroll {...args} items={myitems} />
</div>
);
},
args: {
stickTo: 'all',
scrollBehavior: 'smooth',
headerBehavior: 'stick',
},
};
40 changes: 26 additions & 14 deletions src/components/Scroll/ScrollHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import React, { CSSProperties, useEffect, useMemo, useRef } from 'react';
import { useScrollContext } from './Scroll.provider';
import { Item } from './types';

interface IScrollHeaderProps extends React.PropsWithChildren<React.LiHTMLAttributes<HTMLLIElement>> {
interface IScrollHeaderProps {
path: number[];
itemRender: Item['render'];
itemId?: Item['id'];
}

export const ScrollHeader: React.FC<IScrollHeaderProps> = ({
children,
path,
style = {},
className = '',
...props
}) => {
const { getTopHeadersTotalHeight, getBottomHeadersTotalHeight, scrollToView, stickTo, headerBehavior, addHeader } =
useScrollContext();
export const ScrollHeader: React.FC<IScrollHeaderProps> = ({ path, itemId, itemRender }) => {
const {
getTopHeadersTotalHeight,
getBottomHeadersTotalHeight,
scrollToView,
stickTo,
headerBehavior,
addHeader,
headerCollaspeClose,
headerCollaspeOpen,
collapsedPaths,
} = useScrollContext();

const ref = useRef<HTMLLIElement | null>(null);

Expand Down Expand Up @@ -46,16 +52,22 @@ export const ScrollHeader: React.FC<IScrollHeaderProps> = ({

return (
<li
id={itemId}
onClick={handleClick}
className={`scroll-header ${headerBehavior} ${className}`}
style={{ ...style, ...behaviorStyle }}
className={`scroll-header ${headerBehavior} `}
style={{ ...behaviorStyle }}
aria-label="Scroll Header"
role="heading"
aria-level={1}
{...props}
ref={ref}
>
{children}
{itemRender({
collapse: {
open: () => headerCollaspeOpen(path),
close: () => headerCollaspeClose(path),
isOpen: !collapsedPaths.includes(path.join('-')),
},
})}
</li>
);
};
16 changes: 9 additions & 7 deletions src/components/Scroll/ScrollItem.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import React from 'react';
import { Item } from './types';

export const ScrollItem: React.FC<React.PropsWithChildren<React.LiHTMLAttributes<HTMLLIElement>>> = ({
children,
className = '',
...props
}) => {
interface IScrollItemProps {
itemRender: Item['render'];
itemId?: Item['id'];
}

export const ScrollItem: React.FC<IScrollItemProps> = ({ itemRender, itemId }) => {
return (
<li role="listitem" aria-label="Scroll Item" className={`scroll-item ${className}`} {...props}>
{children}
<li id={itemId} role="listitem" aria-label="Scroll Item" className={`scroll-item `}>
{itemRender({})}
</li>
);
};
32 changes: 4 additions & 28 deletions src/components/Scroll/ScrollList.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,14 @@
import React, { useEffect, useRef } from 'react';
import { useScrollContext } from './Scroll.provider';
import { HeaderBehavior, Items } from './types';
import { ScrollHeader } from './ScrollHeader';
import { ScrollItem } from './ScrollItem';

const getItems = (items: Items, headerBehavior: HeaderBehavior, path: number[] = []) => {
const Wrapper = headerBehavior === 'push' ? 'section' : React.Fragment;

return (
<section>
{items.map((item, index) => {
const currentPath = [...path, index];

if (item.nestedItems?.length) {
return (
<Wrapper key={currentPath.join('-')}>
<ScrollHeader path={currentPath}>{item.render()}</ScrollHeader>
{getItems(item.nestedItems, headerBehavior, currentPath)}
</Wrapper>
);
} else {
return <ScrollItem key={currentPath.join('-')}>{item.render()}</ScrollItem>;
}
})}
</section>
);
};
import { Items } from './types';
import { getItems } from './util/get-items';

interface IScrollListProps {
items: Items;
}

export const ScrollList: React.FC<IScrollListProps> = ({ items }) => {
const { setListRef, headerBehavior } = useScrollContext();
const { setListRef, headerBehavior, collapsedPaths } = useScrollContext();

const listRef = useRef(null);

Expand All @@ -44,7 +20,7 @@ export const ScrollList: React.FC<IScrollListProps> = ({ items }) => {

return (
<ul ref={listRef} className={`scroll-list`}>
{getItems(items, headerBehavior)}
{getItems({ items, headerBehavior, collapsedPaths })}
</ul>
);
};
10 changes: 9 additions & 1 deletion src/components/Scroll/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@ export type StickTo = 'top' | 'bottom' | 'all';

export type HeaderBehavior = 'stick' | 'push' | 'stack' | 'none';

export type Collapse = {
open: () => void;
close: () => void;
isOpen: boolean;
};

type RenderProps = { collapse?: Collapse };

export type Item = {
id?: string;
render: () => JSX.Element;
render: (renderProps: RenderProps) => JSX.Element;
nestedItems?: Item[];
};

Expand Down
36 changes: 36 additions & 0 deletions src/components/Scroll/util/get-items.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { ScrollHeader } from '../ScrollHeader';
import { ScrollItem } from '../ScrollItem';
import { Items, HeaderBehavior } from '../types';

type GetItemsProps = {
items: Items;
headerBehavior: HeaderBehavior;
path?: number[];
collapsedPaths: string[];
};

export const getItems = ({ headerBehavior, items, path = [], collapsedPaths }: GetItemsProps) => {
const Wrapper = headerBehavior === 'push' ? 'section' : React.Fragment;

return (
<section>
{items.map((item, index) => {
const currentPath = [...path, index];

if (item.nestedItems?.length) {
return (
<Wrapper key={currentPath.join('-')}>
<ScrollHeader path={currentPath} itemRender={item.render} itemId={item.id} />
{collapsedPaths.includes(currentPath.join('-'))
? null
: getItems({ items: item.nestedItems, headerBehavior, path: currentPath, collapsedPaths })}
</Wrapper>
);
} else {
return <ScrollItem key={currentPath.join('-')} itemRender={item.render} itemId={item.id} />;
}
})}
</section>
);
};

0 comments on commit 541302a

Please sign in to comment.