Skip to content

Commit f920410

Browse files
authored
Merge pull request #473 from Sasha125588/feat/use-cycle-list
[hook]: Create useCycleList hook #466
2 parents 906bd45 + d88945c commit f920410

6 files changed

Lines changed: 465 additions & 0 deletions

File tree

packages/core/src/bundle/hooks/state.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from './useControllableState/useControllableState';
44
export * from './useCookie/useCookie';
55
export * from './useCookies/useCookies';
66
export * from './useCounter/useCounter';
7+
export * from './useCycleList/useCycleList';
78
export * from './useDefault/useDefault';
89
export * from './useDisclosure/useDisclosure';
910
export * from './useField/useField';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useState } from 'react';
2+
const normalizeIndex = (index, length) => ((index % length) + length) % length;
3+
/**
4+
* @name useCycleList
5+
* @description - Hook that cycles through a list of items
6+
* @category State
7+
* @usage medium
8+
*
9+
* @template Value The type of the item
10+
* @param {Value[]} list The list of items to cycle through
11+
* @param {UseCycleListOptions<Value>} [options] The options for the hook
12+
* @returns {UseCycleListReturn<Value>} An object containing the current item, current index, and functions to cycle through the list
13+
*
14+
* @example
15+
* const { value, index, next, prev, go } = useCycleList(['Dog', 'Cat', 'Lizard']);
16+
*/
17+
export const useCycleList = (list, options = {}) => {
18+
const { initialValue = list[0], fallbackIndex = 0, getIndexOf } = options;
19+
const [index, setIndex] = useState(() => {
20+
if (!list.length) return 0;
21+
const index = getIndexOf ? getIndexOf(initialValue, list) : list.indexOf(initialValue);
22+
return index < 0 ? fallbackIndex : index;
23+
});
24+
const normalizedIndex = list.length ? normalizeIndex(index, list.length) : 0;
25+
const value = list[normalizedIndex];
26+
const go = (targetIndex) => {
27+
if (!list.length) {
28+
setIndex(0);
29+
return undefined;
30+
}
31+
const nextIndex = normalizeIndex(targetIndex, list.length);
32+
setIndex(nextIndex);
33+
return list[nextIndex];
34+
};
35+
const next = (step = 1) => go(normalizedIndex + step);
36+
const prev = (step = 1) => go(normalizedIndex - step);
37+
return { value, index: normalizedIndex, next, prev, go };
38+
};

packages/core/src/hooks/state.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from './useControllableState/useControllableState';
44
export * from './useCookie/useCookie';
55
export * from './useCookies/useCookies';
66
export * from './useCounter/useCounter';
7+
export * from './useCycleList/useCycleList';
78
export * from './useDefault/useDefault';
89
export * from './useDisclosure/useDisclosure';
910
export * from './useField/useField';
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { useCycleList } from '@siberiacancode/reactuse';
2+
import { PauseIcon, RepeatIcon, ShuffleIcon, SkipBackIcon, SkipForwardIcon } from 'lucide-react';
3+
4+
import { cn } from '@/utils/lib';
5+
6+
const TRACKS = [
7+
{
8+
title: 'Resistance',
9+
artist: 'Muse',
10+
accent: 'bg-cyan-400',
11+
cover: 'from-blue-950 via-indigo-700 to-rose-500',
12+
bars: [4, 8, 5, 9, 6, 12, 7, 10, 5, 8, 11, 6, 9, 4, 7, 12],
13+
current: 250,
14+
duration: 346
15+
},
16+
{
17+
title: 'Cold Start',
18+
artist: 'Deep Work',
19+
accent: 'bg-emerald-400',
20+
cover: 'from-emerald-950 via-teal-700 to-lime-400',
21+
bars: [8, 4, 10, 6, 12, 7, 5, 9, 11, 6, 4, 8, 12, 7, 9, 5],
22+
current: 68,
23+
duration: 252
24+
},
25+
{
26+
title: 'Diff Lights',
27+
artist: 'Review Flow',
28+
accent: 'bg-amber-400',
29+
cover: 'from-zinc-950 via-amber-700 to-orange-400',
30+
bars: [5, 11, 7, 4, 8, 12, 6, 10, 7, 5, 9, 12, 4, 8, 6, 10],
31+
current: 136,
32+
duration: 238
33+
},
34+
{
35+
title: 'Tag Cut',
36+
artist: 'Release Run',
37+
accent: 'bg-rose-400',
38+
cover: 'from-rose-950 via-fuchsia-800 to-sky-500',
39+
bars: [10, 6, 8, 12, 5, 9, 4, 7, 11, 8, 6, 12, 5, 10, 7, 4],
40+
current: 47,
41+
duration: 275
42+
}
43+
];
44+
45+
const formatTime = (seconds: number) => {
46+
const minutes = Math.floor(seconds / 60);
47+
const remaining = seconds % 60;
48+
return `${minutes}:${String(remaining).padStart(2, '0')}`;
49+
};
50+
51+
const Demo = () => {
52+
const { value: track, index, next, prev, go } = useCycleList(TRACKS);
53+
54+
const progress = track.current / track.duration;
55+
const activeBars = Math.round(track.bars.length * progress);
56+
57+
const goToRandomTrack = () => {
58+
let nextIndex: number;
59+
do {
60+
nextIndex = Math.floor(Math.random() * TRACKS.length);
61+
} while (nextIndex === index);
62+
63+
go(nextIndex);
64+
};
65+
66+
return (
67+
<section className='flex w-full max-w-sm flex-col items-center p-4'>
68+
<div className='relative w-full pb-16'>
69+
<div className='border-border bg-card relative z-10 flex min-h-[400px] flex-col items-center rounded-[2rem] border p-6 shadow-sm'>
70+
<div
71+
className={cn(
72+
'relative flex size-46 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-gradient-to-br shadow-lg',
73+
track.cover
74+
)}
75+
>
76+
<div className='absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_28%,rgb(0_0_0/0.28)_29%,transparent_55%)]' />
77+
<div className='absolute inset-6 rounded-full border-[18px] border-white/15' />
78+
<div className='absolute inset-14 rounded-full border-[14px] border-white/20' />
79+
<div className='absolute bottom-5 h-20 w-11 rounded-t-full bg-white/85' />
80+
<span className='absolute top-3 left-3 border-b-2 border-current font-mono text-xs font-bold tracking-tight text-white'>
81+
MIX
82+
</span>
83+
</div>
84+
85+
<div className='mt-10 flex w-full min-w-0 flex-col items-center gap-1 text-center'>
86+
<h3 className='text-foreground max-w-full truncate text-3xl font-bold'>
87+
{track.title}
88+
</h3>
89+
<span className='text-muted-foreground max-w-full truncate text-base'>
90+
{track.artist}
91+
</span>
92+
</div>
93+
94+
<div className='mt-auto mb-auto flex items-center justify-center gap-2'>
95+
{TRACKS.map((item, trackIndex) => (
96+
<button
97+
key={item.title}
98+
className={cn(
99+
'size-2.5! rounded-full! p-0!',
100+
trackIndex === index ? item.accent : 'bg-muted-foreground/40'
101+
)}
102+
aria-label={`Select ${item.title}`}
103+
data-variant='unstyled'
104+
type='button'
105+
onClick={() => go(trackIndex)}
106+
/>
107+
))}
108+
</div>
109+
110+
<div className='flex w-full items-center justify-between gap-3'>
111+
<button
112+
aria-label='Repeat'
113+
data-size='icon-sm'
114+
data-variant='ghost'
115+
type='button'
116+
onClick={() => go(0)}
117+
>
118+
<RepeatIcon className='size-4' />
119+
</button>
120+
<button
121+
aria-label='Previous track'
122+
data-size='icon'
123+
data-variant='ghost'
124+
type='button'
125+
onClick={() => prev()}
126+
>
127+
<SkipBackIcon className='size-5' />
128+
</button>
129+
<button aria-label='Pause' className='size-16! rounded-full!' type='button'>
130+
<PauseIcon className='size-6' />
131+
</button>
132+
<button
133+
aria-label='Next track'
134+
data-size='icon'
135+
data-variant='ghost'
136+
type='button'
137+
onClick={() => next()}
138+
>
139+
<SkipForwardIcon className='size-5' />
140+
</button>
141+
<button
142+
aria-label='Shuffle'
143+
data-size='icon-sm'
144+
data-variant='ghost'
145+
type='button'
146+
onClick={goToRandomTrack}
147+
>
148+
<ShuffleIcon className='size-4' />
149+
</button>
150+
</div>
151+
</div>
152+
153+
<div className='border-border bg-card absolute right-10 bottom-4 left-10 flex items-center gap-3 rounded-b-3xl border px-4 py-3 shadow-sm'>
154+
<span className='text-muted-foreground font-mono text-[10px] tabular-nums'>
155+
{formatTime(track.current)}
156+
</span>
157+
158+
<div className='flex h-6 flex-1 items-center justify-center gap-1'>
159+
{track.bars.map((height, barIndex) => (
160+
<span
161+
key={barIndex}
162+
className={cn(
163+
'w-0.5 rounded-full',
164+
barIndex < activeBars ? track.accent : 'bg-muted-foreground/40'
165+
)}
166+
style={{ height: `${height * 2}px` }}
167+
/>
168+
))}
169+
</div>
170+
171+
<span className='text-muted-foreground font-mono text-[10px] tabular-nums'>
172+
{formatTime(track.duration)}
173+
</span>
174+
</div>
175+
</div>
176+
</section>
177+
);
178+
};
179+
180+
export default Demo;

0 commit comments

Comments
 (0)