Skip to content
This repository was archived by the owner on Feb 23, 2024. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
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
30 changes: 30 additions & 0 deletions assets/js/base/interactivity/components.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* External dependencies
*/
import { useMemo, useContext } from 'preact/hooks';
import { deepSignal } from 'deepsignal';

/**
* Internal dependencies
*/
import { component } from './hooks';

export default () => {
const WpContext = ( { children, data, context: { Provider } } ) => {
const signals = useMemo(
() => deepSignal( JSON.parse( data ) ),
[ data ]
);
return <Provider value={ signals }>{ children }</Provider>;
};
component( 'wp-context', WpContext );

const WpShow = ( { children, when, evaluate, context } ) => {
const contextValue = useContext( context );
if ( evaluate( when, { context: contextValue } ) ) {
return children;
}
return <template>{ children }</template>;
};
component( 'wp-show', WpShow );
};
158 changes: 158 additions & 0 deletions assets/js/base/interactivity/directives.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* External dependencies
*/

import { useContext, useMemo, useEffect } from 'preact/hooks';
import { useSignalEffect } from '@preact/signals';
import { deepSignal } from 'deepsignal';

/**
* Internal dependencies
*/
import { directive } from './hooks';
import { prefetch, navigate, hasClientSideTransitions } from './router';

// Until useSignalEffects is fixed:
// https://github.com/preactjs/signals/issues/228
const raf = window.requestAnimationFrame;
const tick = () => new Promise( ( r ) => raf( () => raf( r ) ) );

// Check if current page has client-side transitions enabled.
const clientSideTransitions = hasClientSideTransitions( document.head );

export default () => {
// wp-context
directive(
'context',
( {
directives: {
context: { default: context },
},
props: { children },
context: { Provider },
} ) => {
const signals = useMemo( () => deepSignal( context ), [ context ] );
return <Provider value={ signals }>{ children }</Provider>;
}
);

// wp-effect:[name]
directive( 'effect', ( { directives: { effect }, context, evaluate } ) => {
const contextValue = useContext( context );
Object.values( effect ).forEach( ( path ) => {
useSignalEffect( () => {
evaluate( path, { context: contextValue } );
} );
} );
} );

// wp-on:[event]
directive( 'on', ( { directives: { on }, element, evaluate, context } ) => {
const contextValue = useContext( context );
Object.entries( on ).forEach( ( [ name, path ] ) => {
element.props[ `on${ name }` ] = ( event ) => {
evaluate( path, { event, context: contextValue } );
};
} );
} );

// wp-class:[classname]
directive(
'class',
( {
directives: { class: className },
element,
evaluate,
context,
} ) => {
const contextValue = useContext( context );
Object.keys( className )
.filter( ( n ) => n !== 'default' )
.forEach( ( name ) => {
const result = evaluate( className[ name ], {
className: name,
context: contextValue,
} );
if ( ! result )
element.props.class = element.props.class
.replace(
new RegExp( `(^|\\s)${ name }(\\s|$)`, 'g' ),
' '
)
.trim();
else if (
! new RegExp( `(^|\\s)${ name }(\\s|$)` ).test(
element.props.class
)
)
element.props.class += ` ${ name }`;

useEffect( () => {
// This seems necessary because Preact doesn't change the class names
// on the hydration, so we have to do it manually. It doesn't need
// deps because it only needs to do it the first time.
if ( ! result ) {
element.ref.current.classList.remove( name );
} else {
element.ref.current.classList.add( name );
}
}, [] );
} );
}
);

// wp-bind:[attribute]
directive(
'bind',
( { directives: { bind }, element, context, evaluate } ) => {
const contextValue = useContext( context );
Object.entries( bind )
.filter( ( n ) => n !== 'default' )
.forEach( ( [ attribute, path ] ) => {
element.props[ attribute ] = evaluate( path, {
context: contextValue,
} );
} );
}
);

// wp-link
directive(
'link',
( {
directives: {
link: { default: link },
},
props: { href },
element,
} ) => {
useEffect( () => {
// Prefetch the page if it is in the directive options.
if ( clientSideTransitions && link?.prefetch ) {
prefetch( href );
}
} );

// Don't do anything if it's falsy.
if ( clientSideTransitions && link !== false ) {
element.props.onclick = async ( event ) => {
event.preventDefault();

// Fetch the page (or return it from cache).
await navigate( href );

// Update the scroll, depending on the option. True by default.
if ( link?.scroll === 'smooth' ) {
window.scrollTo( {
top: 0,
left: 0,
behavior: 'smooth',
} );
} else if ( link?.scroll !== false ) {
window.scrollTo( 0, 0 );
}
};
}
}
);
};
88 changes: 88 additions & 0 deletions assets/js/base/interactivity/hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* External dependencies
*/
import { h, options, createContext } from 'preact';
import { useRef } from 'preact/hooks';

/**
* Internal dependencies
*/
import { store } from './wpx';

// Main context.
const context = createContext( {} );

// WordPress Directives.
const directives = {};
export const directive = ( name, cb ) => {
directives[ name ] = cb;
};

// WordPress Components.
const components = {};
export const component = ( name, Comp ) => {
components[ name ] = Comp;
};

// Resolve the path to some property of the wpx object.
const resolve = ( path, context ) => {
let current = { ...store, context };
path.split( '.' ).forEach( ( p ) => ( current = current[ p ] ) );
return current;
};

// Generate the evaluate function.
const getEvaluate =
( { ref } = {} ) =>
( path, extraArgs = {} ) => {
const value = resolve( path, extraArgs.context );
return typeof value === 'function'
? value( {
state: store.state,
...( ref !== undefined ? { ref } : {} ),
...extraArgs,
} )
: value;
};

// Directive wrapper.
const WpDirective = ( { type, wp, props: originalProps } ) => {
const ref = useRef( null );
const element = h( type, { ...originalProps, ref, _wrapped: true } );
const props = { ...originalProps, children: element };
const evaluate = getEvaluate( { ref: ref.current } );
const directiveArgs = { directives: wp, props, element, context, evaluate };

for ( const d in wp ) {
const wrapper = directives[ d ]?.( directiveArgs );
if ( wrapper !== undefined ) props.children = wrapper;
}

return props.children;
};

// Preact Options Hook called each time a vnode is created.
const old = options.vnode;
options.vnode = ( vnode ) => {
const type = vnode.type;
const wp = vnode.props.wp;

if ( typeof type === 'string' && type.startsWith( 'wp-' ) ) {
vnode.props.children = h(
components[ type ],
{ ...vnode.props, context, evaluate: getEvaluate() },
vnode.props.children
);
} else if ( wp ) {
const props = vnode.props;
delete props.wp;
if ( ! props._wrapped ) {
vnode.props = { type: vnode.type, wp, props };
vnode.type = WpDirective;
} else {
delete props._wrapped;
}
}

if ( old ) old( vnode );
};
16 changes: 16 additions & 0 deletions assets/js/base/interactivity/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Internal dependencies
*/
import registerDirectives from './directives';
import registerComponents from './components';
import { init } from './router';

/**
* Initialize the initial vDOM.
*/
document.addEventListener( 'DOMContentLoaded', async () => {
registerDirectives();
registerComponents();
await init();
console.log( 'hydrated!' );
} );
Loading