Repository: FormidableLabs/spectacle
Pull request #831 (2020-02-06 15:10:30)

Task/rewrite slide transitions
- Add support for 3 default transitions `fade`, `slide`, and `none`.
- Add support for custom transitions on Deck or Slide

carlos-kelly: @ryan-roemer Thanks! Yeah I think it's the last little bit of functionality missing from the original. Almost slipped away!

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 60d35a1..3228d1f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,10 +1,10 @@
 # Changelog
 
-=======
-
 ## UNRELEASED
 
 - Update `examples/one-page.html` to `examples/js/index.js` with new script helper.
+- Add support for Deck or Slide-level transitions
+- Add default transitions for Fade, Slide, and None
 
 ## 6.0.0-alpha.7
 
diff --git a/docs/content/api-reference.md b/docs/content/api-reference.md
index d7bf646..00c647e 100644
--- a/docs/content/api-reference.md
+++ b/docs/content/api-reference.md
@@ -21,18 +21,21 @@ These are the bare bones of a Spectacle presentation, the two most essential tag
 
 Wraps the entire presentation and carries most of the overarching slide logic, like theme and template context.
 
-| Props    | Type                                       |
-| -------- | ------------------------------------------ |
-| theme    | [Styled-system theme object](TODO)         |
-| template | [Template render function](#deck-template) |
+| Props            | Type                                                                       |
+| ---------------- | -------------------------------------------------------------------------- |
+| theme            | [Styled-system theme object](/docs/themes)                                 |
+| template         | [Template render function](#deck-template)                                 |
+| transitionEffect | "fade", "slide", "none", or [custom transition object](#transition-object) |
 
 <a name="slide"></a>
 
 ### Slide
 
-Wraps a single slide within your presentation; identifies what is contained to a single view.
+Wraps a single slide within your presentation; identifies what is contained to a single view. The only prop available for Slide is a transition effect specific to this slide. It will override the Deck-specified transition.
 
-No props are directly passed to this tag as the `Deck` and semantic tags within the `Slide` will handle most of your layout and theming.
+| Props            | Type                                                                       |
+| ---------------- | -------------------------------------------------------------------------- |
+| transitionEffect | "fade", "slide", "none", or [custom transition object](#transition-object) |
 
 <a name="semantic-tags"></a>
 
@@ -216,4 +219,29 @@ A template in Spectacle is a fixed overlay of components that are presented on e
 ))>
 ```
 
+<a name="transition-object"></a>
+
+## Transition Object
+
+A transition object defines the animatable CSS properties for three states—`from`, `enter`, `leave`. From is the starting transition. Enter are the styles applied when the slide is in view. Leave are the styles when the slide goes out of view.
+
+An example transition object looks like:
+
+```javascript
+const transition = {
+  from: {
+    position: 'fixed',
+    transform: 'translate(100%, 0%)'
+  },
+  enter: {
+    position: 'fixed',
+    transform: 'translate(0, 0%)'
+  },
+  leave: {
+    position: 'fixed',
+    transform: 'translate(-100%, 0%)'
+  }
+};
+```
+
 ![A screenshot of Progress in use](TODO)
diff --git a/examples/js/index.js b/examples/js/index.js
index 16a52d5..366cb04 100644
--- a/examples/js/index.js
+++ b/examples/js/index.js
@@ -100,7 +100,7 @@ int main()
 }`;
 
 const Presentation = () => (
-  <Deck theme={theme} template={template}>
+  <Deck theme={theme} template={template} transitionEffect="fade">
     <Slide>
       <FlexBox height="100%">
         <SpectacleLogo size={500} />
@@ -125,7 +125,7 @@ const Presentation = () => (
         </p>
       </Notes>
     </Slide>
-    <Slide>
+    <Slide transitionEffect="slide">
       <Heading>Code Blocks</Heading>
       <CodePane fontSize={18} language="cpp" autoFillHeight>
         {cppCodeBlock}
diff --git a/index.d.ts b/index.d.ts
index 04fbb52..d8cde4c 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -11,6 +11,17 @@ declare module 'spectacle' {
     currentSlide: number;
   }) => React.ReactNode;
 
+  export type TransitionEffect =
+    | {
+        enter: Record<string, number | string>;
+        from: Record<string, number | string>;
+        leave: Record<string, number | string>;
+        config: Record<string, number | string>;
+      }
+    | 'fade'
+    | 'slide'
+    | 'none';
+
   export const Deck: React.FC<{
     children: React.ReactNode;
     animationsWhileGoingBack?: boolean;
@@ -20,6 +31,7 @@ declare module 'spectacle' {
     theme?: Record<string, any>;
     textColor?: string;
     template?: TemplateFn;
+    transitionEffect?: TransitionEffect;
   }>;
 
   export const Slide: React.FC<{
@@ -28,6 +40,7 @@ declare module 'spectacle' {
     scaleRatio?: number;
     textColor?: string;
     template?: TemplateFn;
+    transitionEffect?: TransitionEffect;
   }>;
 
   export const Appear: React.FC<{
diff --git a/src/components/deck/index.js b/src/components/deck/index.js
index 6fd89ac..66e53f8 100644
--- a/src/components/deck/index.js
+++ b/src/components/deck/index.js
@@ -33,23 +33,40 @@ const AnimatedDeckDiv = styled(animated.div)`
   width: 100vw;
   position: fixed;
 `;
+AnimatedDeckDiv.displayName = 'AnimatedDeckDiv';
 
-const defaultSlideEffect = {
-  from: {
-    position: 'fixed',
-    transform: 'translate(100%, 0%)'
-  },
-  enter: {
-    position: 'fixed',
-    transform: 'translate(0, 0%)'
+const defaultTransition = {
+  slide: {
+    from: {
+      position: 'fixed',
+      transform: 'translate(100%, 0%)'
+    },
+    enter: {
+      position: 'fixed',
+      transform: 'translate(0, 0%)'
+    },
+    leave: {
+      position: 'fixed',
+      transform: 'translate(-100%, 0%)'
+    },
+    config: { precision: 0 }
   },
-  leave: {
-    position: 'fixed',
-    transform: 'translate(-100%, 0%)'
+  fade: {
+    enter: { opacity: 1 },
+    from: { opacity: 0 },
+    leave: { opacity: 0 },
+    config: { precision: 0 }
   },
-  config: { precision: 0 }
+  none: {
+    enter: {},
+    from: {},
+    leave: {},
+    config: { precision: 0 }
+  }
 };
 
+const builtInTransitions = Object.keys(defaultTransition);
+
 /**
  * Provides top level state/context provider with useDeck hook
  * Should wrap all the presentation components (slides, etc)
@@ -103,7 +120,8 @@ const Deck = props => {
     animationsWhenGoingBack,
     backgroundColor,
     textColor,
-    template
+    template,
+    transitionEffect
   } = props;
   if (React.Children.count(children) === 0) {
     throw new Error('Spectacle must have at least one slide to run.');
@@ -192,7 +210,7 @@ const Deck = props => {
   });
 
   const { runTransition } = React.useContext(TransitionPipeContext);
-  const userTransitionEffect =
+  const slideTransitionEffect =
     filteredChildren[state.currentSlide].props.transitionEffect || {};
   const transitionRef = React.useRef(null);
 
@@ -203,11 +221,37 @@ const Deck = props => {
     runTransition(transitionRef.current);
   }, [transitionRef, state.currentSlide, runTransition]);
 
+  let currentTransition = {};
+
+  if (
+    typeof slideTransitionEffect === 'string' &&
+    builtInTransitions.includes(slideTransitionEffect)
+  ) {
+    currentTransition = defaultTransition[slideTransitionEffect];
+  } else if (
+    typeof slideTransitionEffect === 'object' &&
+    Object.keys(slideTransitionEffect).length !== 0
+  ) {
+    currentTransition = slideTransitionEffect;
+  } else if (
+    typeof transitionEffect === 'string' &&
+    builtInTransitions.includes(transitionEffect)
+  ) {
+    currentTransition = defaultTransition[transitionEffect];
+  } else if (
+    typeof transitionEffect === 'object' &&
+    Object.keys(transitionEffect).length !== 0
+  ) {
+    currentTransition = transitionEffect;
+  } else {
+    currentTransition = defaultTransition['slide'];
+  }
+
   const transitions = useTransition(state.currentSlide, p => p, {
     ref: transitionRef,
-    enter: () => userTransitionEffect.enter || defaultSlideEffect.enter,
-    leave: userTransitionEffect.leave || defaultSlideEffect.leave,
-    from: userTransitionEffect.from || defaultSlideEffect.from,
+    enter: currentTransition.enter,
+    leave: currentTransition.leave,
+    from: currentTransition.from,
     unique: true,
     immediate: state.immediate
   });
@@ -297,7 +341,15 @@ Deck.propTypes = {
   loop: PropTypes.bool.isRequired,
   template: PropTypes.func,
   textColor: PropTypes.string,
-  theme: PropTypes.object
+  theme: PropTypes.object,
+  transitionEffect: PropTypes.oneOfType([
+    PropTypes.objectOf({
+      from: PropTypes.object,
+      enter: PropTypes.object,
+      leave: PropTypes.object
+    }),
+    PropTypes.oneOf(['fade', 'slide', 'none'])
+  ])
 };
 
 const ConnectedDeck = props => (
diff --git a/src/components/slide.js b/src/components/slide.js
index 958ace2..940d74d 100644
--- a/src/components/slide.js
+++ b/src/components/slide.js
@@ -100,6 +100,11 @@ const Slide = props => {
     const useVerticalRatio =
       clientWidth / clientHeight > slideWidth / slideHeight;
     const clientRects = slideRef.current.getClientRects();
+
+    if (!clientRects || clientRects.length === 0) {
+      return;
+    }
+
     setOrigin({
       x: useVerticalRatio
         ? `${(clientWidth - clientRects[0].width) / 2 / (1 - ratio)}px`
@@ -167,7 +172,15 @@ Slide.propTypes = {
   scaleRatio: PropTypes.number,
   slideNum: PropTypes.number,
   template: PropTypes.func,
-  textColor: PropTypes.string
+  textColor: PropTypes.string,
+  transitionEffect: PropTypes.oneOfType([
+    PropTypes.shape({
+      from: PropTypes.object,
+      enter: PropTypes.object,
+      leave: PropTypes.object
+    }),
+    PropTypes.oneOf(['fade', 'slide', 'none'])
+  ])
 };
 
 Slide.defaultProps = {


Test patch:
diff --git a/src/components/deck/deck.test.js b/src/components/deck/deck.test.js
new file mode 100644
index 0000000..fc3b34a
--- /dev/null
+++ b/src/components/deck/deck.test.js
@@ -0,0 +1,111 @@
+import React from 'react';
+import Enzyme, { mount } from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
+import Deck from './index';
+import Slide from '../slide';
+
+Enzyme.configure({ adapter: new Adapter() });
+
+describe('<Deck />', () => {
+  it('should have the correct number of children and ignore invalid children', () => {
+    const tree = mount(
+      <Deck>
+        <Slide>
+          <div>Slide 1</div>
+        </Slide>
+        <Slide>
+          <div>Slide 2</div>
+        </Slide>
+        <div>This is an invalid child</div>
+      </Deck>
+    );
+    expect(tree.find('Slide').props().numberOfSlides).toBe(2);
+  });
+
+  it('should have the default transition when none is provided as a prop', () => {
+    const tree = mount(
+      <Deck>
+        <Slide>
+          <div>Slide 1</div>
+        </Slide>
+      </Deck>
+    );
+    expect(tree.find('AnimatedDeckDiv').props().style).toHaveProperty(
+      'transform'
+    );
+    expect(tree.find('AnimatedDeckDiv').props().style).not.toHaveProperty(
+      'opacity'
+    );
+  });
+
+  it('should pass the transition effect to children slides', () => {
+    const tree = mount(
+      <Deck transitionEffect="fade">
+        <Slide>
+          <div>Slide 1</div>
+        </Slide>
+      </Deck>
+    );
+    expect(tree.find('AnimatedDeckDiv').props().style).toHaveProperty(
+      'opacity'
+    );
+    expect(tree.find('AnimatedDeckDiv').props().style).not.toHaveProperty(
+      'transform'
+    );
+  });
+
+  it('should give precedence to the slide prop over the deck.', () => {
+    const tree = mount(
+      <Deck transitionEffect="fade">
+        <Slide transitionEffect="slide">
+          <div>Slide 1</div>
+        </Slide>
+      </Deck>
+    );
+    expect(tree.find('AnimatedDeckDiv').props().style).not.toHaveProperty(
+      'opacity'
+    );
+    expect(tree.find('AnimatedDeckDiv').props().style).toHaveProperty(
+      'transform'
+    );
+  });
+
+  it('should have no transition when the none prop is provided.', () => {
+    const tree = mount(
+      <Deck transitionEffect="none">
+        <Slide>
+          <div>Slide 1</div>
+        </Slide>
+      </Deck>
+    );
+    expect(tree.find('AnimatedDeckDiv').props().style).not.toHaveProperty(
+      'opacity'
+    );
+    expect(tree.find('AnimatedDeckDiv').props().style).not.toHaveProperty(
+      'transform'
+    );
+  });
+
+  it('should pass a custom transition to the slide', () => {
+    const tree = mount(
+      <Deck
+        transitionEffect={{
+          from: { color: 'red' },
+          enter: { color: 'blue' },
+          leave: { color: 'white' }
+        }}
+      >
+        <Slide>
+          <div>Slide 1</div>
+        </Slide>
+      </Deck>
+    );
+    expect(tree.find('AnimatedDeckDiv').props().style).toHaveProperty('color');
+    expect(tree.find('AnimatedDeckDiv').props().style).not.toHaveProperty(
+      'transform'
+    );
+    expect(tree.find('AnimatedDeckDiv').props().style).not.toHaveProperty(
+      'opacity'
+    );
+  });
+});