diff --git a/wp-content/themes/core/assets/js/editor/block-animations.js b/wp-content/themes/core/assets/js/editor/block-animations.js new file mode 100644 index 00000000..c8dddd1a --- /dev/null +++ b/wp-content/themes/core/assets/js/editor/block-animations.js @@ -0,0 +1,550 @@ +/** + * @module block-animation + * + * @description handles setting up animation settings for blocks + * + * theme.json settings: + * "animationType": [ + * { "label": "None", "value": "none" }, + * { "label": "Fade In", "value": "fade-in" } + * ], + * "animationDirection": { + * "fade-in": [ + * { "label": "Top", "value": "top" }, + * { "label": "Bottom", "value": "bottom" } + * ] + * }, + * "animationSpeeds": [ + * { "label": "200ms", "value": "0.2s" }, + * { "label": "800ms", "value": "0.8s" } + * ], + * "animationDelays": [ + * { "label": "0", "value": "0s" }, + * { "label": "200ms", "value": "0.2s" }, + * { "label": "800ms", "value": "0.8s" } + * ], + * "animationEasings": [ + * { "label": "Ease In", "value": "ease-in" }, + * { "label": "Ease Out", "value": "ease-out" } + * ], + * "animationPosition": [ + * { "label": "25%", "value": "25" }, + * { "label": "50%", "value": "50" }, + * ], + * "animationIncludes": [ + * "core/group", + * "core/heading" + * ], + * "animationExcludes": [ + * "core/group", + * "core/heading" + * ], + */ + +import { InspectorControls } from '@wordpress/block-editor'; +import { PanelBody, SelectControl, ToggleControl } from '@wordpress/components'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { Fragment } from '@wordpress/element'; +import { addFilter } from '@wordpress/hooks'; +import { __ } from '@wordpress/i18n'; +import themeJson from '../../../theme.json'; + +const state = {}; + +/** + * @function applyAnimationProps + * + * @description updates props on the block with new animation settings + * + * @param {Object} props + * @param {Object} block + * @param {Object} attributes + * + * @return {Object} updated props object + */ +const applyAnimationProps = ( props, block, attributes ) => { + // return default props if block isn't in the includes array + if ( + state.includes.length > 0 && + ! state.includes.includes( block.name ) + ) { + return props; + } + + // return default props if block is in the excludes array + if ( state.excludes.length > 0 && state.excludes.includes( block.name ) ) { + return props; + } + + const { + animationType, + animationDirection, + animationDuration, + animationDelay, + animationMobileDisableDelay, + animationEasing, + animationTrigger, + animationPosition, + } = attributes; + + if ( animationType === undefined || animationType === 'none' ) { + return props; + } + + if ( props.className === undefined ) { + props.className = ''; + } + + props.className = `${ props.className } is-animated-on-scroll-${ animationPosition } tribe-animation-type-${ animationType } tribe-animation-direction-${ animationDirection }`; + + if ( animationDuration !== undefined && animationDuration ) { + props.style = { + ...props.style, + '--tribe-animation-speed': animationDuration, + }; + } + + if ( animationDelay !== undefined && animationDelay ) { + props.style = { + ...props.style, + '--tribe-animation-delay': animationDelay, + }; + } + + if ( + animationMobileDisableDelay !== undefined && + animationMobileDisableDelay + ) { + props.className = `${ props.className } tribe-animation-mobile-disable-delay`; + } + + if ( animationEasing !== undefined && animationEasing ) { + props.style = { + ...props.style, + '--tribe-animation-easing': animationEasing, + }; + } + + if ( animationTrigger !== undefined && animationTrigger ) { + props.className = `${ props.className } tribe-animate-multiple`; + } + + return props; +}; + +/** + * @function animationControls + * + * @description creates component that overrides the edit functionality of the block with new animation controls + */ +const animationControls = createHigherOrderComponent( ( BlockEdit ) => { + return ( props ) => { + const { attributes, setAttributes, isSelected, name } = props; + + // return default Edit function if block isn't in the includes array + if ( state.includes.length > 0 && ! state.includes.includes( name ) ) { + return ; + } + + // return default Edit function if block is in the excludes array + if ( state.excludes.length > 0 && state.excludes.includes( name ) ) { + return ; + } + + const { + animationType, + animationDirection, + animationDuration, + animationDelay, + animationMobileDisableDelay, + animationEasing, + animationTrigger, + animationPosition, + } = attributes; + + let blockClass = + attributes.className !== undefined ? attributes.className : ''; + const blockStyles = { ...props.style }; + + if ( animationType !== undefined && animationType !== 'none' ) { + // set block class for animation direction & animation position, if it's not set to the default + blockClass = `${ blockClass } is-animated-on-scroll-${ animationPosition } tribe-animation-type-${ animationType } tribe-animation-direction-${ animationDirection }`; + + // set block styles for animation duration + if ( animationDuration !== undefined && animationDuration ) { + blockStyles[ '--tribe-animation-speed' ] = animationDuration; + } + + // set block styles for animation delay + if ( animationDelay !== undefined && animationDelay ) { + blockStyles[ '--tribe-animation-delay' ] = animationDelay; + } + + // set block class for disabling animation delays on mobile + if ( + animationMobileDisableDelay !== undefined && + animationMobileDisableDelay + ) { + blockClass = `${ blockClass } tribe-animation-mobile-disable-delay`; + } + + // set block styles for animation easing + if ( animationEasing !== undefined && animationEasing ) { + blockStyles[ '--tribe-animation-easing' ] = animationEasing; + } + + // set block class for triggering animation multiple times + if ( animationTrigger !== undefined && animationTrigger ) { + blockClass = `${ blockClass } tribe-animate-multiple`; + } + } + + const blockProps = { + ...props, + attributes: { + ...attributes, + className: blockClass, + }, + style: blockStyles, + }; + + return ( + + + { isSelected && ( + + + { + setAttributes( { + animationType: newValue, + } ); + } } + options={ state.type } + /> + { animationType === undefined || + ( animationType !== 'none' && ( + <> + { + setAttributes( { + animationDirection: + newValue, + } ); + } } + options={ + state.direction[ animationType ] + } + /> + + setAttributes( { + animationDuration: newValue, + } ) + } + options={ state.duration } + /> + + setAttributes( { + animationDelay: newValue, + } ) + } + options={ state.delay } + /> + + setAttributes( { + animationMobileDisableDelay: + newValue, + } ) + } + /> + + setAttributes( { + animationEasing: newValue, + } ) + } + options={ state.easing } + /> + + setAttributes( { + animationTrigger: newValue, + } ) + } + /> + + setAttributes( { + animationPosition: newValue, + } ) + } + options={ state.position } + /> + + ) ) } + + + ) } + + ); + }; +}, 'animationControls' ); + +/** + * @function addAnimationAttributes + * + * @description add new attributes to blocks for animation settings + * + * @param {Object} settings + * @param {string} name + * + * @return {Object} returns updates settings object + */ +const addAnimationAttributes = ( settings, name ) => { + // return default settings if block isn't in the includes array + if ( state.includes.length > 0 && ! state.includes.includes( name ) ) { + return settings; + } + + // return default settings if block is in the excludes array + if ( state.excludes.length > 0 && state.excludes.includes( name ) ) { + return settings; + } + + if ( settings?.attributes !== undefined ) { + settings.attributes = { + ...settings.attributes, + animationType: { + type: 'string', + default: 'none', + }, + animationDirection: { + type: 'string', + default: 'bottom', + }, + animationDuration: { + type: 'string', + default: '0.6s', + }, + animationDelay: { + type: 'string', + default: '0s', + }, + animationMobileDisableDelay: { + type: 'boolean', + default: false, + }, + animationEasing: { + type: 'string', + default: 'cubic-bezier(0.390, 0.575, 0.565, 1.000)', + }, + animationTrigger: { + type: 'boolean', + default: false, + }, + animationPosition: { + type: 'string', + default: '25', + }, + }; + } + + return settings; +}; + +/** + * @function registerFilters + * + * @description register block filters for adding animation controls + */ +const registerFilters = () => { + addFilter( + 'blocks.registerBlockType', + 'tribe/add-animation-options', + addAnimationAttributes + ); + + addFilter( + 'editor.BlockEdit', + 'tribe/animation-advanced-control', + animationControls + ); + + addFilter( + 'blocks.getSaveContent.extraProps', + 'tribe/apply-animation-classes', + applyAnimationProps + ); +}; + +/** + * @function initializeSettings + * + * @description pull settings from theme.json or add default settings + * + * @todo work with design to handle defining these defaults + * @todo do we need to handle translations if pulling from theme.json? + */ +const initializeSettings = () => { + state.type = themeJson?.settings?.animationType ?? [ + { label: __( 'None', 'tribe' ), value: 'none' }, + { label: __( 'Fade In', 'tribe' ), value: 'fade-in' }, + ]; + // direction is an object with keys for each animation type + state.direction = themeJson?.settings?.animationDirection ?? { + 'fade-in': [ + { label: __( 'Bottom', 'tribe' ), value: 'bottom' }, + { label: __( 'Right', 'tribe' ), value: 'right' }, + { label: __( 'Top Right', 'tribe' ), value: 'top-right' }, + { label: __( 'Bottom Right', 'tribe' ), value: 'bottom-right' }, + { label: __( 'Left', 'tribe' ), value: 'left' }, + { label: __( 'Top Left', 'tribe' ), value: 'top-left' }, + { label: __( 'Bottom Left', 'tribe' ), value: 'bottom-left' }, + { label: __( 'Forward', 'tribe' ), value: 'forward' }, + { label: __( 'Back', 'tribe' ), value: 'back' }, + { label: __( 'Top', 'tribe' ), value: 'top' }, + { label: __( 'Simple', 'tribe' ), value: 'simple' }, + ], + }; + state.duration = themeJson?.settings?.animationDuration ?? [ + { label: __( '300ms', 'tribe' ), value: '0.3s' }, + { label: __( '600ms', 'tribe' ), value: '0.6s' }, + { label: __( '900ms', 'tribe' ), value: '0.9s' }, + { label: __( '1200ms', 'tribe' ), value: '1.2s' }, + { label: __( '1600ms', 'tribe' ), value: '1.6s' }, + ]; + state.delay = themeJson?.settings?.animationDelay ?? [ + { label: __( '0', 'tribe' ), value: '0s' }, + { label: __( '300ms', 'tribe' ), value: '0.3s' }, + { label: __( '600ms', 'tribe' ), value: '0.6s' }, + { label: __( '900ms', 'tribe' ), value: '0.9s' }, + { label: __( '1200ms', 'tribe' ), value: '1.2s' }, + { label: __( '1600ms', 'tribe' ), value: '1.6s' }, + { label: __( '2000ms', 'tribe' ), value: '2s' }, + ]; + state.easing = themeJson?.settings?.animationEasing ?? [ + { + label: __( 'Ease Out Sine', 'tribe' ), + value: 'cubic-bezier(0.390, 0.575, 0.565, 1.000)', + }, + { + label: __( 'Ease In Sine', 'tribe' ), + value: 'cubic-bezier(0.470, 0.000, 0.745, 0.715)', + }, + { + label: __( 'Ease In Out Sine', 'tribe' ), + value: 'cubic-bezier(0.445, 0.050, 0.550, 0.950)', + }, + { + label: __( 'Ease Out Quad', 'tribe' ), + value: 'cubic-bezier(0.250, 0.460, 0.450, 0.940)', + }, + { + label: __( 'Ease In Quad', 'tribe' ), + value: 'cubic-bezier(0.550, 0.085, 0.680, 0.530)', + }, + { + label: __( 'Ease In Out Quad', 'tribe' ), + value: 'cubic-bezier(0.455, 0.030, 0.515, 0.955)', + }, + ]; + state.position = themeJson?.settings?.animationPosition ?? [ + { label: __( '25%', 'tribe' ), value: '25' }, + { label: __( '50%', 'tribe' ), value: '50' }, + { label: __( '75%', 'tribe' ), value: '75' }, + { label: __( '100%', 'tribe' ), value: '100' }, + ]; + state.includes = themeJson.settings.animationIncludes ?? []; + state.excludes = themeJson.settings.animationExcludes ?? []; +}; + +/** + * @function init + * + * @description initializes this modules functions + */ +const init = () => { + // initialize settings + initializeSettings(); + + // handle registering block filters + registerFilters(); +}; + +export default init; diff --git a/wp-content/themes/core/assets/js/editor/ready.js b/wp-content/themes/core/assets/js/editor/ready.js index 72776bba..eb9fb98e 100644 --- a/wp-content/themes/core/assets/js/editor/ready.js +++ b/wp-content/themes/core/assets/js/editor/ready.js @@ -4,13 +4,16 @@ * @description The core dispatcher for the dom ready event javascript. */ +import blockAnimations from './block-animations'; + /** * @function init * @description The core dispatcher for init across the codebase. */ const init = () => { - // intentionally left blank for now + // load animations + blockAnimations(); console.info( 'Editor: Initialized all javascript that targeted document ready.' diff --git a/wp-content/themes/core/assets/js/theme/animate-on-scroll.js b/wp-content/themes/core/assets/js/theme/animate-on-scroll.js new file mode 100644 index 00000000..662648d1 --- /dev/null +++ b/wp-content/themes/core/assets/js/theme/animate-on-scroll.js @@ -0,0 +1,107 @@ +/** + * @module + * @exports init + * @description functions for handling elements that should change on scroll + */ + +const el = {}; + +/** + * @function handleIntersection + * + * @description Callback function for when an element comes into view + * + * @param {*} entries + */ +const handleIntersection = ( entries ) => { + entries.forEach( ( entry ) => { + if ( entry.isIntersecting ) { + entry.target.classList.remove( 'is-exiting-view' ); + entry.target.classList.add( 'is-scrolled-into-view' ); + entry.target.classList.add( 'is-scrolled-into-view-first-time' ); + } else { + entry.target.classList.remove( 'is-scrolled-into-view' ); + entry.target.classList.add( 'is-exiting-view' ); + } + } ); +}; + +/** + * @function attachObservers + * + * @description attach intersection observers to elements + */ +const attachObservers = () => { + if ( el.aosElements.length ) { + const observer = new window.IntersectionObserver( handleIntersection, { + threshold: 0.25, + } ); + + el.aosElements.forEach( ( element ) => observer.observe( element ) ); + } + + if ( el.aos50Elements.length ) { + const observer = new window.IntersectionObserver( handleIntersection, { + threshold: 0.5, + } ); + + el.aos50Elements.forEach( ( element ) => observer.observe( element ) ); + } + + if ( el.aos75Elements.length ) { + const observer = new window.IntersectionObserver( handleIntersection, { + threshold: 0.75, + } ); + + el.aos75Elements.forEach( ( element ) => observer.observe( element ) ); + } + + if ( el.aosFullElements.length ) { + const observer = new window.IntersectionObserver( handleIntersection, { + threshold: 1, + } ); + + el.aosFullElements.forEach( ( element ) => + observer.observe( element ) + ); + } +}; + +/** + * @function cacheElements + * + * @description Cache elements for this module + */ +const cacheElements = () => { + /** + * Note that the below selectors would need to change if the values of + * the animationPosition values change in theme.json (or the + * block-animations.js file). + */ + + // grabs elements that should animate when the element is 25% in view + el.aosElements = document.querySelectorAll( '.is-animated-on-scroll-25' ); + + // grabs elements that should animate when 50% of the element is in view + el.aos50Elements = document.querySelectorAll( '.is-animated-on-scroll-50' ); + + // grabs elements that should animate when 75% of the element is in view + el.aos75Elements = document.querySelectorAll( '.is-animated-on-scroll-75' ); + + // grabs elements that should animate when the entire element is in view + el.aosFullElements = document.querySelectorAll( + '.is-animated-on-scroll-100' + ); +}; + +/** + * @function init + * + * @description Kick off this module's functionality + */ +const init = () => { + cacheElements(); + attachObservers(); +}; + +export default init; diff --git a/wp-content/themes/core/assets/js/theme/ready.js b/wp-content/themes/core/assets/js/theme/ready.js index 8bc21896..dd2ad60a 100644 --- a/wp-content/themes/core/assets/js/theme/ready.js +++ b/wp-content/themes/core/assets/js/theme/ready.js @@ -10,6 +10,8 @@ import { debounce } from 'utils/tools.js'; import resize from 'common/resize.js'; import viewportDims from 'common/viewport-dims.js'; +import animateOnScroll from './animate-on-scroll'; + /** * @function bindEvents * @description Bind global event listeners here, @@ -33,6 +35,9 @@ const init = () => { bindEvents(); + // run animations + animateOnScroll(); + console.info( 'Theme: Initialized all javascript that targeted document ready.' ); diff --git a/wp-content/themes/core/assets/pcss/global/animation.pcss b/wp-content/themes/core/assets/pcss/global/animation.pcss new file mode 100644 index 00000000..d55c65e6 --- /dev/null +++ b/wp-content/themes/core/assets/pcss/global/animation.pcss @@ -0,0 +1,212 @@ +/* ------------------------------------------------------------------------- + * + * Global: Animations + * Works with our block-animations code in core/assets/js/admin along with + * our FE IntersectionObserver in core/assets/js/utils/animate-on-scroll.js + * to create animations with settings on individual blocks + * + * ------------------------------------------------------------------------- */ + +/* Setup default animation variables so they can be overridden per block */ +:root { + --tribe-animation-delay: 0s; + --tribe-animation-speed: 0.6s; + --tribe-animation-easing: cubic-bezier(0.39, 0.575, 0.565, 1); + --tribe-animation-offset: 50px; +} + +/* ------------------------------------------------------------------------- + * Animated Element / Block + * + * We set opacity to 0 here because all of our animations involve fades + * Also set the default transition for the block, if the "Animation should + * trigger every time the element is in the viewport" setting is checked, + * this transition will handle in & out animations. + * ------------------------------------------------------------------------- */ + +.tribe-animation-type-fade-in { + + /* Only run animations if the users has no preference on reduced motion */ + @media (prefers-reduced-motion: no-preference) { + opacity: 0; + transition: opacity var(--tribe-animation-speed) var(--tribe-animation-delay) var(--tribe-animation-easing); + + /* turn off delay for mobile if setting is set */ + @media (--mq-wp-mobile-max) { + + &.tribe-animation-mobile-disable-delay { + transition-delay: 0s !important; + } + } + + /* ------------------------------------------------------------------------- + * Fade In (Simple) + * Don't run "first time" animation if multiple is set + * ------------------------------------------------------------------------- */ + + &.tribe-animation-direction-simple.is-scrolled-into-view, + &:not(.tribe-animate-multiple).tribe-animation-direction-simple.is-scrolled-into-view-first-time { + opacity: 1; + } + + /* ------------------------------------------------------------------------- + * Fade In Up (Bottom) + * Don't run "first time" animation if multiple is set + * ------------------------------------------------------------------------- */ + + &.tribe-animation-direction-bottom { + transition-property: all; + transform: translateY(var(--tribe-animation-offset)); + + &.is-scrolled-into-view, + &:not(.tribe-animate-multiple).is-scrolled-into-view-first-time { + opacity: 1; + transform: translateY(0); + } + } + + /* ------------------------------------------------------------------------- + * Fade In (from the) Bottom Left (Bottom Left) + * Don't run "first time" animation if multiple is set + * ------------------------------------------------------------------------- */ + + &.tribe-animation-direction-bottom-left { + transition-property: all; + transform: translate(calc(var(--tribe-animation-offset) * -1), var(--tribe-animation-offset)); + + &.is-scrolled-into-view, + &:not(.tribe-animate-multiple).is-scrolled-into-view-first-time { + opacity: 1; + transform: translate(0, 0); + } + } + + /* ------------------------------------------------------------------------- + * Fade In (from the) Bottom Right (Bottom Right) + * Don't run "first time" animation if multiple is set + * ------------------------------------------------------------------------- */ + + &.tribe-animation-direction-bottom-right { + transition-property: all; + transform: translate(var(--tribe-animation-offset), var(--tribe-animation-offset)); + + &.is-scrolled-into-view, + &:not(.tribe-animate-multiple).is-scrolled-into-view-first-time { + opacity: 1; + transform: translate(0, 0); + } + } + + /* ------------------------------------------------------------------------- + * Fade In Down (Top) + * Don't run "first time" animation if multiple is set + * ------------------------------------------------------------------------- */ + + &.tribe-animation-direction-top { + transition-property: all; + transform: translateY(calc(var(--tribe-animation-offset) * -1)); + + &.is-scrolled-into-view, + &:not(.tribe-animate-multiple).is-scrolled-into-view-first-time { + opacity: 1; + transform: translateY(0); + } + } + + /* ------------------------------------------------------------------------- + * Fade In (from the) Top Right (Top Right) + * Don't run "first time" animation if multiple is set + * ------------------------------------------------------------------------- */ + + &.tribe-animation-direction-top-right { + transition-property: all; + transform: translate(var(--tribe-animation-offset), calc(var(--tribe-animation-offset) * -1)); + + &.is-scrolled-into-view, + &:not(.tribe-animate-multiple).is-scrolled-into-view-first-time { + opacity: 1; + transform: translate(0, 0); + } + } + + /* ------------------------------------------------------------------------- + * Fade In (from the) Top Left (Top Left) + * Don't run "first time" animation if multiple is set + * ------------------------------------------------------------------------- */ + + &.tribe-animation-direction-top-left { + transition-property: all; + transform: translate(calc(var(--tribe-animation-offset) * -1), calc(var(--tribe-animation-offset) * -1)); + + &.is-scrolled-into-view, + &:not(.tribe-animate-multiple).is-scrolled-into-view-first-time { + opacity: 1; + transform: translate(0, 0); + } + } + + /* ------------------------------------------------------------------------- + * Fade In (from the) Right (Left) + * Don't run "first time" animation if multiple is set + * ------------------------------------------------------------------------- */ + + &.tribe-animation-direction-left { + transition-property: all; + transform: translateX(calc(var(--tribe-animation-offset) * -1)); + + &.is-scrolled-into-view, + &:not(.tribe-animate-multiple).is-scrolled-into-view-first-time { + opacity: 1; + transform: translateX(0); + } + } + + /* ------------------------------------------------------------------------- + * Fade In (from the) Left (Right) + * Don't run "first time" animation if multiple is set + * ------------------------------------------------------------------------- */ + + &.tribe-animation-direction-right { + transition-property: all; + transform: translateX(var(--tribe-animation-offset)); + + &.is-scrolled-into-view, + &:not(.tribe-animate-multiple).is-scrolled-into-view-first-time { + opacity: 1; + transform: translateX(0); + } + } + + /* ------------------------------------------------------------------------- + * Fade In Forward + * Don't run "first time" animation if multiple is set + * ------------------------------------------------------------------------- */ + + &.tribe-animation-direction-forward { + transition-property: all; + transform: scale(0.85); + + &.is-scrolled-into-view, + &:not(.tribe-animate-multiple).is-scrolled-into-view-first-time { + opacity: 1; + transform: scale(1); + } + } + + /* ------------------------------------------------------------------------- + * Fade In Backward + * Don't run "first time" animation if multiple is set + * ------------------------------------------------------------------------- */ + + &.tribe-animation-direction-back { + transition-property: all; + transform: scale(1.15); + + &.is-scrolled-into-view, + &:not(.tribe-animate-multiple).is-scrolled-into-view-first-time { + opacity: 1; + transform: scale(1); + } + } + } +} diff --git a/wp-content/themes/core/assets/pcss/theme.pcss b/wp-content/themes/core/assets/pcss/theme.pcss index b9875d82..95230e9a 100644 --- a/wp-content/themes/core/assets/pcss/theme.pcss +++ b/wp-content/themes/core/assets/pcss/theme.pcss @@ -11,6 +11,7 @@ /* Global Theme Styles */ @import "global/reset.pcss"; @import "typography/anchors.pcss"; +@import "global/animation.pcss"; /* Patterns */ @import "cards/post.pcss";