Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create carousel hooks #132

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
164 changes: 164 additions & 0 deletions packages/react-animation/src/carousel/hooks/useCarousel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { useEventListener, useRefValue, useResizeObserver } from '@mediamonks/react-hooks';
import gsap from 'gsap';
import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react';
import { useDraggable } from '../../useDraggable/useDraggable.js';

export type CarouselContext = {
draggable?: RefObject<Draggable | null>;
triggerRef: RefObject<HTMLElement | null>;
proxyRef: RefObject<HTMLDivElement | null>;

update?(): void;
};

export type CarouselTransform = (
value: number,
child: HTMLElement,
context: CarouselContext,
) => number;

export enum CarouselType {
X = 'x',
Y = 'y',
}

export type CarouselOptions = {
triggerRef: RefObject<HTMLElement>;
variables?: Omit<Draggable.Vars, 'type'> & {
type?: CarouselType;
};
transforms?: ReadonlyArray<CarouselTransform>;
};

export class ProxyUpdateEvent extends Event {
public static type = 'proxyupdate';

public constructor() {
super(ProxyUpdateEvent.type);
}
}

export function useCarousel({
triggerRef,
variables = {},
transforms = [],
}: CarouselOptions): CarouselContext {
const proxy = useMemo(
() => (typeof window === 'undefined' ? null : document.createElement('div')),
VictorGa marked this conversation as resolved.
Show resolved Hide resolved
[],
);

const type = useMemo(
() => variables.type ?? CarouselType.X,
// type cannot change once draggable is initialized
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
VictorGa marked this conversation as resolved.
Show resolved Hide resolved

const transformsRef = useRefValue(transforms);
const variablesRef = useRefValue(variables);
const proxyRef = useRefValue(proxy);

const contextRef = useRef<CarouselContext>({
triggerRef,
proxyRef,
});

const onDrag = useCallback(() => {
if (proxy === null) {
throw new Error('Cannot update carousel, proxy is null');
}

const position = gsap.getProperty(proxy, type) as number;

const children = [
...(triggerRef.current?.children ?? []),
] as unknown as ReadonlyArray<HTMLElement>;

for (const child of children) {
let childPosition = position;

for (const transform of transformsRef.current ?? []) {
childPosition = transform(childPosition, child, contextRef.current);
}

gsap.set(child, {
[type]: childPosition,
force3D: true,
});
}

proxy.dispatchEvent(new ProxyUpdateEvent());
}, [proxy, type, triggerRef, transformsRef]);

// Expose onDrag so that carousel can be updated externally
contextRef.current.update = onDrag;

const draggable = useDraggable(proxyRef, {
trigger: triggerRef,
variables: {
...variables,
type,
inertia: true,
throwProps: true,
zIndexBoost: false,
edgeResistance: 1,
minDuration: variables.minDuration ?? 1,
maxDuration: variables.maxDuration ?? 1,
bounds: variables.bounds,
snap: variables.snap,
},
async onMount() {
if (draggable.current === null) {
return;
}

contextRef.current.draggable = draggable;

updateBounds();
onDrag();
},
});

const updateBounds = useCallback(() => {
if (draggable.current === null) {
return;
}

draggable.current.applyBounds(variablesRef.current?.bounds ?? null);
}, [draggable, variablesRef]);

const onResize = useRafCallback(() => {
VictorGa marked this conversation as resolved.
Show resolved Hide resolved

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In foundation I added an extra check for the example AutoHeight to work without an insane amount of re-renders. Please see full code changes in foundation.

if (type === CarouselType.X && containerWidth.current === triggerRef.current?.offsetWidth) { return; }

let position = gsap.getProperty(proxyRef.current, type) as number;

if (typeof variables.snap === 'function') {
position = variables.snap(position);
}

gsap.set(proxyRef.current, { [type]: position });
onDrag();
}, []);

// Apply bounds when they change and update the position
useEffect(() => {
updateBounds();
onDrag();
}, [draggable, onDrag, updateBounds, variables.bounds]);

useEventListener(draggable, 'drag', () => {
variablesRef.current?.onDrag?.();
onDrag();
});

useEventListener(draggable, 'throwupdate', () => {
variablesRef.current?.onThrowUpdate?.();
onDrag();
});

useResizeObserver(triggerRef, onResize);
useMutationObserver(triggerRef, onResize, {
VictorGa marked this conversation as resolved.
Show resolved Hide resolved
childList: true,
});

return contextRef.current;
}
46 changes: 46 additions & 0 deletions packages/react-animation/src/carousel/hooks/useCarouselBounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useResizeObserver } from '@mediamonks/react-hooks';
import { type RefObject, useCallback, useState } from 'react';
import { useMutationObserver } from '../useMutationObserver';
import { CarouselType } from './useCarousel.js';
import { useCarouselCalculations } from './useCarouselCalculations.js';

type CarouselBoundsOptions = {
type?: CarouselType;
};
export function useCarouselBounds(
sliderRef: RefObject<HTMLElement>,
{ type = CarouselType.X }: CarouselBoundsOptions = {},
): Draggable.BoundsMinMax | null {
const [bounds, setBounds] = useState<Draggable.BoundsMinMax | null>(null);
const { getElementOffset } = useCarouselCalculations(type);

const onResize = useCallback(() => {
VictorGa marked this conversation as resolved.
Show resolved Hide resolved
if (sliderRef.current === null) {
// eslint-disable-next-line no-console
console.warn("Can't set bounds, sliderRef is undefined");
return;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In foundation I added an extra check for the example AutoHeight to work without an insane amount of re-renders. Please see full code changes in foundation.

if (type === CarouselType.X && containerWidth.current === sliderRef.current.offsetWidth) { return; }

const children = [...sliderRef.current.children] as unknown as ReadonlyArray<HTMLElement>;

const firstChild = children.at(0);
const lastChild = children.at(-1);

if (firstChild === undefined || lastChild === undefined) {
// eslint-disable-next-line no-console
console.warn("Can't set bounds, sliderRef has no children");
return;
}

const size = getElementOffset(lastChild) - getElementOffset(firstChild);

setBounds(type === CarouselType.X ? { minX: -size, maxX: 0 } : { minY: -size, maxY: 0 });
}, [sliderRef, getElementOffset, type]);

useResizeObserver(sliderRef, onResize);
useMutationObserver(sliderRef, onResize, {
childList: true,
});

return bounds;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useMemo } from 'react';
import { CarouselType } from './useCarousel.js';

export const enum SizeType {
Offset = 'offset',
Client = 'client',
}

export function getCarouselCalculations(type: CarouselType): {
getElementOffset<T extends HTMLElement>(element: T): number;
getElementSize<T extends HTMLElement>(element: T, sizeType?: SizeType): number;
Comment on lines +10 to +11
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be missing something, but what's the benefit of the T generic here? It's only used in one place (the element parameter), so the type could be specified just there as well?

} {
return {
getElementOffset<T extends HTMLElement>(element: T): number {
if (type === CarouselType.X) {
return element.offsetLeft;
}
return element.offsetTop;
},

getElementSize<T extends HTMLElement>(element: T, sizeType = SizeType.Offset): number {
if (type === CarouselType.X) {
return element[`${sizeType}Width`];
}
return element[`${sizeType}Height`];
},
};
}

/**
* Carousels can go in either the `x` or `y` direction and in order to keep the carousel code as clean as possible we
* have this proxy method that returns the correct values based on the type of the carousel.
*
* @param type
*/
export function useCarouselCalculations(
type: CarouselType,
): ReturnType<typeof getCarouselCalculations> {
return useMemo(() => getCarouselCalculations(type), [type]);
}
119 changes: 119 additions & 0 deletions packages/react-animation/src/carousel/hooks/useCarouselControls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { useRefValue } from '@mediamonks/react-hooks';
import gsap from 'gsap';
import { useMemo } from 'react';
import { CarouselType, type CarouselContext } from './useCarousel.js';
import { useCarouselCalculations } from './useCarouselCalculations.js';
import { useCarouselIndex } from './useCarouselIndex.js';

export type CarouselControls = {
next(): Promise<void>;
previous(): Promise<void>;
index(index: number): Promise<void>;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added the option to set initial index to Foundation. See comments below for the code I've added.
Still behaves a bit buggy:

setInitial(index: number): Promise<void>;

};

export type UseCarouselControlsOptions = {
alignment?: number;
type?: CarouselType;
snap(value: number): number;
};

export const defaultTweenVariables = {
duration: 0.8,
ease: 'power3.out',
} satisfies gsap.TweenVars;

export function useCarouselControls(
carousel: CarouselContext,
options: UseCarouselControlsOptions,
tweenVariables: gsap.TweenVars = {},
): CarouselControls {
const { alignment = 0, type = CarouselType.X, snap } = options;

const _tweenVariables = useMemo(
() => ({ ...defaultTweenVariables, ...tweenVariables }),
// eslint-disable-next-line react-hooks/exhaustive-deps
Object.values(tweenVariables),
);

const { getElementSize, getElementOffset } = useCarouselCalculations(type);

const index = useCarouselIndex(carousel, options.type, options.alignment);
const indexRef = useRefValue(index);

return useMemo<CarouselControls>(() => {
function updatePosition<T extends keyof CarouselControls>(control?: T): CarouselControls[T] {
VictorGa marked this conversation as resolved.
Show resolved Hide resolved
return async (targetIndex?: number) => {
if (indexRef.current === null || indexRef.current === -1) {
throw new Error("Can't update position, index is undefined");
}

const { triggerRef, proxyRef, update } = carousel;

const children = [
...(triggerRef.current?.children ?? []),
] as unknown as ReadonlyArray<HTMLElement>;

const currentChild = children.at(indexRef.current);

if (currentChild === undefined) {
throw new Error(`Can't update position, triggerRef has no children`);
}

let position = gsap.getProperty(proxyRef.current, type) as number;

switch (control) {
case 'next': {
position -= getElementSize(currentChild);
break;
}

case 'previous': {
position += getElementSize(currentChild);
break;
}

default: {
if (targetIndex === undefined) {
throw new Error("Can't update position, index is undefined");
}

const child = children.at(targetIndex);

if (child === undefined) {
throw new Error(`Can't find child for index ${targetIndex}`);
}

// prettier-ignore
position = -(getElementOffset(child) + (getElementSize(child) * alignment))
break;
}
}

position = snap(position);

await gsap.to(proxyRef.current, {
..._tweenVariables,
[type]: position,
onUpdate() {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onUpdate() { if (!immediate) { update?.(); } }
onComplete() { update?.(); }

update?.();
},
Comment on lines +99 to +101
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I presume this would work as well?

Suggested change
onUpdate() {
update?.();
},
onUpdate: update,

});
};
}

return {
next: updatePosition('next'),
previous: updatePosition('previous'),
index: updatePosition(),
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setInitial: getPositionUpdater('index', true)

}, [
_tweenVariables,
alignment,
carousel,
getElementOffset,
getElementSize,
indexRef,
snap,
type,
]);
}
21 changes: 21 additions & 0 deletions packages/react-animation/src/carousel/hooks/useCarouselIndex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useCallback, useState } from 'react';
import { getIndex } from './getIndex.js';
import { CarouselType, type CarouselContext } from './useCarousel.js';
import { useProxyUpdate } from './useProxyUpdate.js';

export function useCarouselIndex(
carousel: CarouselContext,
type = CarouselType.X,
alignment = 0.5,
): number {
const [index, setIndex] = useState(0);

useProxyUpdate(
carousel,
useCallback(() => {
setIndex(getIndex(carousel, type, alignment));
}, [alignment, carousel, type]),
);

return index;
}
Loading