desko27 / react-call

⚛️ 📡 Call your React components
https://react-call.desko.dev
MIT License
453 stars 7 forks source link

How to handle closing animations? #9

Closed asnaeb closed 2 months ago

asnaeb commented 2 months ago

Libraries like Radix put a data attribute on their components based on open/closed state so that you can apply css animations accordingly. Whit this library, calling .end, causes the component to be immediately unmounted so the only way I can think of to apply closing animations is performing the animation via javascript, then calling .end on animation end. Is this how it should be done or am I missing something?

I really loved the idea behind this library and to use it according to my needs, I ended up writing my own version which is as follows

import {ReactNode, useEffect, useState} from "react";

interface Openable<Result> {
  resolve(value?: Result): void;
  state: {
    open: boolean;
    onOpenChange(value: boolean): void;
  };
}

function createOpenable<
  Args = void,
  Result = void,
  Props extends object = object,
>(UserComponent: (args: Props & {openable: Openable<Result>; args: Args}) => ReactNode, timeout = 0) {
  let $resolve: ((value?: Result) => void) | null = null;
  let $setOpen: ((value: boolean) => void) | null = null;
  let $setArgs: ((value: Args) => void) | null = null;

  return {
    Component(props: Props) {
      const [open, setOpen] = useState(false);
      const [args, setArgs] = useState({} as Args);

      useEffect(() => {
        $setOpen = setOpen;
        $setArgs = setArgs;
        return () => {
          $setOpen = null;
          $setArgs = null;
          $resolve = null;
        };
      }, []);

      const openable: Openable<Result> = {
        state: {
          open,
          onOpenChange(value) {
            if (!value) {
              $resolve?.();
              setTimeout(setArgs.bind(null, {} as Args), timeout);
            }
            setOpen(value);
          }
        },
        resolve(value: Result) {
          if (typeof $resolve !== "function") {
            throw Error();
          }
          $resolve(value);
          setOpen(false);
          setTimeout(setArgs.bind(null, {} as Args), timeout);
        }
      };

      return <UserComponent {...props} openable={openable} args={args as Args}/>;
    },
    open(args: Args) {
      if (typeof $setOpen !== "function") {
        throw Error();
      }
      if (args) {
        if (typeof $setArgs !== "function") {
          throw Error();
        }
        $setArgs(args);
      }
      $setOpen(true);
      return new Promise<Result | undefined>(resolve => $resolve = resolve);
    }
  };
}

export {createOpenable};

I don't need the component to stack, nor to be unmounted but I just want to open and close it imperatively. I would love to know if there are plans to support such a thing and if I can make anything to contribute!

desko27 commented 2 months ago

Hi @asnaeb! Thanks for your request and glad you liked the idea!

I'm really interested in supporting animations since they're pretty common nowadays. Here are a couple of constraints to consider though, both being important commitments to react-call:

I like the call stack approach because it actually covers a variety of scenarios. Even if visual stacked elements are not desired, imagine nested modals, or even opening a new dialog when the closing animation from the previous one is still not finished.

Still, you're completely right: the component gets unmounted immediately which makes closing animations impossible. Please take a look at #10 in which I tried to cover all that's been discussed with:

The bad news is that the bundle size exceeds 500B and I can see that it's gonna be hard to impossible to reduce it enough to fit in. But I still managed to get <550B, which may be all right considering how usual animations are on the web.

desko27 commented 2 months ago

I've published react-call@next as a pre-release for testing purposes. Could you please give it a try and let me know?

Of course, any idea on how to reduce the bundle size is more than welcome!

asnaeb commented 2 months ago

I tested it and I've been able to achieve what I wanted by setting the delay to the animation duration. A minimal example using Radix Dialog component would be

import * as Dialog from "@radix-ui/react-dialog";

const CallableDialog = createCallable(({call}) => (
  <Dialog.Root open={!call.ended} onOpenChange={value => !value && call.end()}>
    <Dialog.Content className="data-[state=closed]:animate-my-animation">
      <button onClick={() => call.end()}>close</button>
    <Dialog.Content>
  </Dialog.Root>
), 200);

Setting a delay was the same conclusion I had thought of in my take when I came across the issue that new args got cleaned up before the animation ended (just updated to show it). So this looks good to me. Plus, I don't think that 500 or 550B would be that much of a difference honestly.

desko27 commented 2 months ago

This will get delivered in v1.3.0. @asnaeb thanks for contributing!!