corvée(dépendances) ajoute Carbon Fields

This commit is contained in:
gcch 2024-08-09 18:45:01 +02:00
commit 62368587e5
459 changed files with 72750 additions and 26 deletions

View file

@ -0,0 +1,315 @@
/**
* External dependencies.
*/
import cx from 'classnames';
import { Component, Fragment } from '@wordpress/element';
import { ToolbarGroup, PanelBody } from '@wordpress/components';
import {
InnerBlocks,
BlockControls,
InspectorControls
} from '@wordpress/editor';
import { withSelect } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import {
get,
map,
find
} from 'lodash';
/**
* Carbon Fields dependencies.
*/
import { getFieldType } from '@carbon-fields/core';
/**
* Internal dependencies.
*/
import './style.scss';
import Field from '../field';
import ServerSideRender from '../server-side-render';
class BlockEdit extends Component {
/**
* Local state.
*
* @type {Object}
*/
state = {
mode: this.props.container.settings.mode,
currentTab: this.props.supportsTabs
? Object.keys( this.props.container.settings.tabs )[ 0 ]
: null
};
/**
* Handles the change of the field's value.
*
* @param {string} fieldId
* @param {mixed} value
* @return {void}
*/
handleFieldChange = ( fieldId, value ) => {
const { attributes, setAttributes } = this.props;
const fieldName = fieldId.replace( /^.+__(.+)?$/, '$1' );
setAttributes( {
data: {
...attributes.data,
[ fieldName ]: value
}
} );
}
/**
* Handles changing of the mode.
*
* @return {void}
*/
handleModeChange = () => {
this.setState( {
mode: this.isInEditMode ? 'preview' : 'edit'
} );
}
/**
* Handles changing on the tabs.
*
* @param {string} tab
* @return {void}
*/
handleTabChange = ( tab ) => {
this.setState( {
currentTab: tab
} );
}
/**
* Returns whether the block is in edit mode.
*
* @return {boolean}
*/
get isInEditMode() {
return this.state.mode === 'edit';
}
/**
* Returns whether the block is in edit mode.
*
* @return {boolean}
*/
get isInPreviewMode() {
return this.state.mode === 'preview';
}
/**
* Renders a field.
*
* @param {Object} field
* @param {number} index
* @return {Object}
*/
renderField = ( field, index ) => {
const {
clientId,
container,
attributes
} = this.props;
const FieldEdit = getFieldType( field.type, 'block' );
if ( ! FieldEdit ) {
return null;
}
const id = `cf-${ clientId }__${ field.base_name }`;
const value = get( attributes.data, field.base_name, field.default_value );
return (
<Field
key={ index }
id={ id }
field={ field }
>
<FieldEdit
id={ id }
containerId={ container.id }
blockId={ clientId }
value={ value }
field={ field }
name={ field.base_name }
onChange={ this.handleFieldChange }
/>
</Field>
);
}
/**
* Renders the fields in tabs.
*
* @param {string[]} fieldNames
* @return {Object[]}
*/
renderTabbedFields( fieldNames ) {
const { fields } = this.props;
return map( fieldNames, ( fieldName, index ) => {
const field = find( fields, [ 'name', fieldName ] );
return this.renderField( field, index );
} );
}
/**
* Renders the fields that aren't in tabs.
*
* @return {Object}
*/
renderNonTabbedFields() {
return (
<div className="cf-block__fields">
{ this.props.fields.map( this.renderField ) }
</div>
);
}
/**
* Render the component.
*
* @return {Object}
*/
render() {
const { currentTab } = this.state;
const {
clientId,
container,
supportsTabs,
supportsPreview,
supportsInnerBlocks
} = this.props;
const innerBlocks = ( ( supportsInnerBlocks && this.isInEditMode ) && (
<div className="cf-block__inner-blocks">
<InnerBlocks
template={ container.settings.inner_blocks.template }
templateLock={ container.settings.inner_blocks.template_lock }
allowedBlocks={ container.settings.inner_blocks.allowed_blocks }
/>
</div>
) );
return (
<Fragment>
{ container.settings.inner_blocks.position === 'above' && innerBlocks }
{ supportsPreview && (
<BlockControls>
<ToolbarGroup label="Options" controls={ [ {
icon: this.isInEditMode
? 'visibility'
: 'hidden',
title: this.isInEditMode
? __( 'Show preview', 'carbon-fields-ui' )
: __( 'Hide preview', 'carbon-fields-ui' ),
onClick: this.handleModeChange
} ] } />
</BlockControls>
) }
{ ( this.isInEditMode && supportsTabs ) && (
<div className="cf-block__tabs">
<ul className="cf-block__tabs-list">
{ map( container.settings.tabs, ( fieldNames, tabName ) => {
const classes = cx(
'cf-block__tabs-item',
{
'cf-block__tabs-item--current': tabName === currentTab
}
);
return (
<li
key={ tabName }
className={ classes }
onClick={ () => this.handleTabChange( tabName ) }
>
{ tabName }
</li>
);
} ) }
</ul>
</div>
) }
{ this.isInEditMode && (
supportsTabs
? (
map( container.settings.tabs, ( fieldNames, tabName ) => {
return (
<div className="cf-block__fields" key={ tabName } hidden={ tabName !== currentTab }>
{ this.renderTabbedFields( fieldNames ) }
</div>
);
} )
)
: (
this.renderNonTabbedFields()
)
) }
{ this.isInPreviewMode && (
<div className="cf-block__preview">
<ServerSideRender clientId={ clientId } />
</div>
) }
{ container.settings.inner_blocks.position === 'below' && innerBlocks }
{ this.isInPreviewMode && (
<InspectorControls>
{
supportsTabs
? (
map( container.settings.tabs, ( fieldNames, tabName ) => {
return (
<PanelBody key={ tabName } title={ tabName }>
<div className="cf-block__fields">
{ this.renderTabbedFields( fieldNames ) }
</div>
</PanelBody>
);
} )
)
: (
<PanelBody title={ __( 'Fields', 'carbon-fields-ui' ) }>
{ this.renderNonTabbedFields() }
</PanelBody>
)
}
</InspectorControls>
) }
</Fragment>
);
}
}
export default withSelect( ( select, { clientId, name } ) => {
const { hasBlockSupport } = select( 'core/blocks' );
const { getBlockRootClientId } = select( 'core/block-editor' );
const {
getContainerDefinitionByBlockName,
getFieldDefinitionsByBlockName
} = select( 'carbon-fields/blocks' );
const rootClientId = getBlockRootClientId( clientId );
return {
container: getContainerDefinitionByBlockName( name ),
fields: getFieldDefinitionsByBlockName( name ),
supportsTabs: hasBlockSupport( name, 'tabs' ),
supportsPreview: hasBlockSupport( name, 'preview' ) && ! rootClientId,
supportsInnerBlocks: hasBlockSupport( name, 'innerBlocks' )
};
} )( BlockEdit );

View file

@ -0,0 +1,51 @@
/* ==========================================================================
Block
========================================================================== */
.cf-block__tabs {
margin-bottom: $size-base * 4;
}
.cf-block__tabs-list {
.wp-block & {
display: flex;
padding: 0;
margin: 0;
list-style: none outside none;
}
}
.cf-block__tabs-item {
padding: $size-base * 2;
margin: 0;
font-family: $wp-font;
font-size: $wp-font-size;
line-height: 1;
cursor: pointer;
&--current {
box-shadow: 0 3px 0 $wp-color-medium-blue;
}
}
.cf-block__fields {
display: flex;
flex-wrap: wrap;
&[hidden] {
display: none;
}
.wp-block & {
margin: $size-base * -2;
}
}
.cf-block__preview {
min-height: 100px;
}
.cf-block__inner-blocks .block-list-appender {
margin-top: 32px;
margin-bottom: 32px;
}

View file

@ -0,0 +1,41 @@
/**
* External dependencies.
*/
import { Component } from '@wordpress/element';
import { InnerBlocks } from '@wordpress/editor';
class BlockSave extends Component {
/**
* Render the component.
*
* @return {null}
*/
render() {
return null;
}
}
/**
* Adds the content of inner blocks to the saved content.
*
* @param {mixed} element
* @param {Object} blockType
* @return {mixed}
*/
function addInnerBlocksContent( element, blockType ) {
if ( ! /^carbon\-fields\/.+$/.test( blockType.name ) ) {
return element;
}
if ( ! blockType.supports.innerBlocks ) {
return element;
}
return (
<InnerBlocks.Content />
);
}
wp.hooks.addFilter( 'blocks.getSaveElement', 'carbon-fields/blocks', addInnerBlocksContent );
export default BlockSave;

View file

@ -0,0 +1,11 @@
/**
* Carbon Fields dependencies.
*/
import { Field, withFilters } from '@carbon-fields/core';
/**
* Internal dependencies.
*/
import './style.scss';
export default withFilters( 'carbon-fields.field-wrapper.block' )( Field );

View file

@ -0,0 +1,47 @@
/* ==========================================================================
Field
========================================================================== */
.cf-field {
.block-editor & {
font-family: $wp-font;
font-size: $wp-font-size;
line-height: $wp-line-height;
color: $gb-dark-gray-500;
padding: $size-base * 2;
min-width: 0;
}
.wp-block-widget-area & {
padding: 6.5px 20px;
}
.edit-post-sidebar .cf-block__fields > & {
padding: $size-base 0 0;
}
.block-editor .cf-complex & {
border-width: 1px 1px 0 0;
border-style: solid;
border-color: $wp-color-gray-light-500;
}
.edit-post-sidebar .cf-complex & {
border-color: $gb-dark-gray-150;
}
}
.cf-field__label {
font-size: 13px;
font-weight: 600;
color: $wp-color-dark-gray;
.block-editor & {
margin-bottom: 4px;
}
.wp-block-widget-area & {
margin-bottom: 6.5px;
}
}

View file

@ -0,0 +1,18 @@
/**
* The external dependencies.
*/
import { __, sprintf } from '@wordpress/i18n';
/**
* Render a notice to inform the user that the field doesn't have
* any options.
*
* @return {React.Element}
*/
const NotSupportedField = ( { type } ) => (
<em>
{ sprintf( __( `Field of type '%s' is not supported in Gutenberg.`, 'carbon-fields-ui' ), [ type ] ) }
</em>
);
export default NotSupportedField;

View file

@ -0,0 +1,154 @@
/**
* External dependencies.
*/
import apiFetch from '@wordpress/api-fetch';
import { Component, RawHTML } from '@wordpress/element';
import { Placeholder, Spinner } from '@wordpress/components';
import { withSelect } from '@wordpress/data';
import { serialize } from '@wordpress/blocks';
import { __, sprintf } from '@wordpress/i18n';
import { isEqual, debounce } from 'lodash';
/**
* This component is slightly modified version of the `ServerSideRender` component
* that comes by default with Gutenberg.
*
* @see https://github.com/WordPress/gutenberg/tree/master/packages/components/src/server-side-render
*/
class ServerSideRender extends Component {
/**
* Local state.
*
* @type {Object}
*/
state = {
response: null
};
/**
* Lifecycle hook.
*
* @return {void}
*/
componentDidMount() {
this.isStillMounted = true;
// Do the initial rendering.
this.fetch( this.props );
// Only debounce once the initial fetch occurs to ensure that the first
// renders show data as soon as possible.
this.fetch = debounce( this.fetch, 500 );
}
/**
* Lifecycle hook.
*
* @return {void}
*/
componentWillUnmount() {
this.isStillMounted = false;
}
/**
* Lifecycle hook.
*
* @param {Object} prevProps
* @return {void}
*/
componentDidUpdate( prevProps ) {
if ( ! isEqual( prevProps, this.props ) ) {
this.fetch( this.props );
}
}
/**
* Fetch the preview of the block.
*
* @param {Object} props
* @return {void}
*/
fetch( props ) {
if ( ! this.isStillMounted ) {
return;
}
if ( null !== this.state.response ) {
this.setState( { response: null } );
}
const { block } = props;
// Store the latest fetch request so that when we process it, we can
// check if it is the current request, to avoid race conditions on slow networks.
const fetchRequest = this.currentFetchRequest = apiFetch( {
method: 'post',
path: '/carbon-fields/v1/block-renderer',
data: {
name: block.name,
content: serialize( [ block ] )
}
} )
.then( ( response ) => {
if ( this.isStillMounted && fetchRequest === this.currentFetchRequest && response && response.rendered ) {
this.setState( {
response: response.rendered
} );
}
} )
.catch( ( error ) => {
if ( this.isStillMounted && fetchRequest === this.currentFetchRequest ) {
this.setState( {
response: {
error: true,
errorMsg: error.message
}
} );
}
} );
}
/**
* Render the component.
*
* @return {Object}
*/
render() {
const { response } = this.state;
const { className } = this.props;
if ( ! response ) {
return (
<Placeholder className={ className }>
<Spinner />
</Placeholder>
);
} else if ( response.error ) {
return (
<Placeholder className={ className }>
{ sprintf( __( 'Error loading block: %s', 'carbon-fields-ui' ), response.errorMsg ) }
</Placeholder>
);
} else if ( ! response.length ) {
return (
<Placeholder className={ className }>
{ __( 'No results found.', 'carbon-fields-ui' ) }
</Placeholder>
);
}
return (
<RawHTML key="html" className={ className }>
{ response }
</RawHTML>
);
}
}
export default withSelect( ( select, { clientId } ) => {
const { getBlock } = select( 'core/block-editor' );
return {
block: getBlock( clientId )
};
} )( ServerSideRender );

View file

@ -0,0 +1,55 @@
/**
* External dependencies.
*/
import { addFilter } from '@wordpress/hooks';
import { select } from '@wordpress/data';
import { get, find } from 'lodash';
/**
* Carbon Fields dependencies.
*/
import { withProps } from '@carbon-fields/core';
/**
* Internal dependencies.
*/
import './style.scss';
addFilter( 'carbon-fields.association.block', 'carbon-fields/blocks', withProps( ( props ) => {
return {
hierarchyResolver() {
// Get the block that contains the field.
const block = select( 'core/block-editor' ).getBlock( props.blockId );
// Get the path.
const path = props.id.split( '__' );
// Remove the chunk that contains the block identifier.
path.shift();
// Get the hierarchy.
let hierarchy = path.shift();
let accessor = `data.${ hierarchy }`;
// Visit every branch in the tree so we can get the full hierarchy.
while ( path.length > 0 ) {
const chunk = path.shift();
const isGroup = chunk.indexOf( 'cf-' ) === 0;
if ( isGroup ) {
const groups = get( block.attributes, `${ accessor }` );
const group = find( groups, [ '_id', chunk ] );
const groupIndex = groups.indexOf( group );
accessor = `${ accessor }.${ groupIndex }`;
hierarchy = `${ hierarchy }[${ groupIndex }]:${ group._type }/`;
} else {
accessor = `${ accessor }.${ chunk }`;
hierarchy = `${ hierarchy }${ chunk }`;
}
}
return hierarchy;
}
};
} ) );

View file

@ -0,0 +1,72 @@
/* ==========================================================================
Association
========================================================================== */
.cf-association__cols {
.block-editor & {
border-top-width: 1px;
margin-top: 4px;
}
.edit-post-sidebar & {
border-color: $gb-dark-gray-150;
flex-direction: column;
}
.edit-post-sidebar &::before {
background-color: $gb-dark-gray-150;
display: none;
}
}
.cf-association__counter {
.edit-post-sidebar & {
display: none;
}
}
.cf-association__option {
.edit-post-sidebar & {
height: 40px;
}
.edit-post-sidebar & + & {
border-top-color: $gb-dark-gray-150;
}
}
.cf-association__option-thumb {
.edit-post-sidebar & {
display: none;
}
}
.cf-association__option-content {
.edit-post-sidebar & {
align-items: flex-start;
flex-direction: column;
min-width: 0;
}
}
.cf-association__option-title {
.edit-post-sidebar & {
width: 100%;
}
}
.cf-association__option-actions {
.edit-post-sidebar .cf-association__col:first-child & {
min-width: 0;
}
.edit-post-sidebar & {
margin-left: auto;
}
}
.cf-association__option-action {
.edit-post-sidebar &--edit {
display: none;
}
}

View file

@ -0,0 +1,4 @@
/**
* Internal dependencies.
*/
import './style.scss';

View file

@ -0,0 +1,15 @@
/* ------------------------------------------------------------ *\
Block Preview iFrame
\* ------------------------------------------------------------ */
body.block-editor-iframe__body {
.cf-block-preview {
display: block;
}
// Hide all other fields except the HTML fields with preview
// Also check if there is preview field at all or just show all fields
.cf-block__fields:has(.cf-block-preview) .cf-field:not(.cf-block-preview) {
display: none;
}
}

View file

@ -0,0 +1,325 @@
/**
* External dependencies.
*/
import produce from 'immer';
import { Component } from '@wordpress/element';
import { addFilter } from '@wordpress/hooks';
import {
get,
set,
find,
omit,
assign,
mapKeys,
without,
cloneDeep,
findIndex
} from 'lodash';
/**
* Carbon Fields dependencies.
*/
import { uniqueId } from '@carbon-fields/core';
/**
* Internal dependencies.
*/
import './style.scss';
import Field from '../../components/field';
class ComplexField extends Component {
/**
* Local state.
*
* @type {Object}
*/
state = {
collapsedGroups: this.props.value.reduce( ( accumulator, { _id, _type } ) => {
const group = find( this.props.field.groups, [ 'name', _type ] );
if ( ! group.collapsed ) {
return accumulator;
}
return accumulator.concat( _id );
}, [] )
};
/**
* Returns a list of group values.
*
* @return {Array}
*/
getGroupValues() {
return this.props.value.map( ( group ) => {
const values = mapKeys( omit( group, [ '_id', '_type' ] ), ( value, key ) => key.replace( /\-/g, '_' ) );
return [ group._type, values ];
} );
}
/**
* Handles adding of group.
*
* @param {Object} group
* @param {Function} callback
* @return {void}
*/
handleAddGroup = ( group, callback ) => {
const {
id,
value,
onChange
} = this.props;
const data = {};
data._id = uniqueId();
data._type = group.name;
group.fields.reduce( ( accumulator, field ) => {
accumulator[ field.base_name ] = field.default_value;
return accumulator;
}, data );
onChange( id, value.concat( data ) );
callback( data );
}
/**
* Handles cloning of group.
*
* @param {Object} group
* @param {Function} callback
* @return {void}
*/
handleCloneGroup = ( group, callback ) => {
const {
id,
value,
onChange
} = this.props;
const index = value.indexOf( group );
const clonedGroup = cloneDeep( group );
clonedGroup._id = uniqueId();
onChange( id, produce( value, ( draft ) => {
draft.splice( index + 1, 0, clonedGroup );
} ) );
callback( clonedGroup );
}
/**
* Handles removing of group.
*
* @param {Object} group
* @return {void}
*/
handleRemoveGroup = ( group ) => {
const {
id,
value,
onChange
} = this.props;
const groupIndex = findIndex( value, [ '_id', group._id ] );
onChange( id, produce( value, ( draft ) => {
draft.splice( groupIndex, 1 );
} ) );
this.setState( ( { collapsedGroups } ) => ( {
collapsedGroups: without( collapsedGroups, group._id )
} ) );
}
/**
* Handles expanding/collapsing of group.
*
* @param {string} groupId
* @return {void}
*/
handleToggleGroup = ( groupId ) => {
this.setState( ( { collapsedGroups } ) => {
if ( collapsedGroups.indexOf( groupId ) > -1 ) {
collapsedGroups = without( collapsedGroups, groupId );
} else {
collapsedGroups = [ ...collapsedGroups, groupId ];
}
return { collapsedGroups };
} );
}
/**
* Handles expanding/collapsing of all groups.
*
* @return {void}
*/
handleToggleAllGroups = () => {
const { value } = this.props;
this.setState( ( { collapsedGroups } ) => {
if ( collapsedGroups.length !== value.length ) {
collapsedGroups = value.map( ( group ) => group._id );
} else {
collapsedGroups = [];
}
return { collapsedGroups };
} );
}
/**
* Handles setuping of group.
*
* @param {Object} group
* @param {Object} props
* @return {Object}
*/
handleGroupSetup = ( group, props ) => {
const fields = get( find( this.props.field.groups, [ 'name', group._type ] ), 'fields', [] );
const values = omit( group, [ '_id', '_type' ] );
return assign( {}, props, {
id: group._id,
fields: fields,
collapsed: this.state.collapsedGroups.indexOf( group._id ) > -1,
context: 'block',
values
} );
}
/**
* Handles setuping of group's field.
*
* @param {Object} field
* @param {Object} props
* @param {Object} groupProps
* @return {Array}
*/
handleGroupFieldSetup = ( field, props, groupProps ) => {
const { blockId } = this.props;
const id = `${ this.props.id }__${ groupProps.id }__${ field.base_name }`;
const value = get( groupProps, `values.${ field.base_name }` );
return [ Field, assign( {}, props, {
key: id,
id: id,
name: field.base_name,
containerId: this.props.containerId,
blockId,
field,
value,
onChange: this.handleGroupFieldChange
} ) ];
}
/**
* Handles the change of group field.
*
* @param {string} fieldId
* @param {mixed} fieldValue
* @return {void}
*/
handleGroupFieldChange = ( fieldId, fieldValue ) => {
const {
id,
value,
onChange
} = this.props;
onChange( id, produce( value, ( draft ) => {
const path = fieldId.split( '__' );
const fieldName = path.pop();
const group = find( draft, [ '_id', path.pop() ] );
set( group, fieldName, fieldValue );
} ) );
}
/**
* Renders the component.
*
* @return {Object}
*/
render() {
const {
handleGroupSetup,
handleGroupFieldSetup,
handleAddGroup,
handleCloneGroup,
handleRemoveGroup,
handleToggleGroup,
handleToggleAllGroups
} = this;
const { value, children } = this.props;
const groupValues = this.getGroupValues();
const allGroupsAreCollapsed = this.state.collapsedGroups.length === value.length;
return children( {
groupValues,
allGroupsAreCollapsed,
handleGroupSetup,
handleGroupFieldSetup,
handleAddGroup,
handleCloneGroup,
handleRemoveGroup,
handleToggleGroup,
handleToggleAllGroups
} );
}
}
addFilter( 'carbon-fields.complex.block', 'carbon-fields/blocks', ( OriginalComplexField ) => ( props ) => {
const {
id,
name,
value,
error,
field
} = props;
return (
<ComplexField { ...props }>
{ ( {
groupValues,
allGroupsAreCollapsed,
handleGroupSetup,
handleGroupFieldSetup,
handleAddGroup,
handleCloneGroup,
handleRemoveGroup,
handleToggleGroup,
handleToggleAllGroups
} ) => (
<OriginalComplexField
groupIdKey="_id"
groupFilterKey="_type"
id={ id }
name={ name }
value={ value }
error={ error }
field={ field }
groupValues={ groupValues }
allGroupsAreCollapsed={ allGroupsAreCollapsed }
onGroupSetup={ handleGroupSetup }
onGroupFieldSetup={ handleGroupFieldSetup }
onAddGroup={ handleAddGroup }
onCloneGroup={ handleCloneGroup }
onRemoveGroup={ handleRemoveGroup }
onToggleGroup={ handleToggleGroup }
onToggleAllGroups={ handleToggleAllGroups }
onChange={ props.onChange }
/>
) }
</ComplexField>
);
} );

View file

@ -0,0 +1,89 @@
/* ==========================================================================
Complex
========================================================================== */
.cf-complex__placeholder-label {
font-size: $wp-font-size;
line-height: $wp-line-height;
}
.cf-complex__inserter-menu {
.block-editor & {
z-index: 10;
list-style: none outside none;
border: 1px solid $gb-light-gray-500;
box-shadow: 0 3px 30px rgba($gb-dark-gray-900, .1);
background-color: $color-white;
&::before,
&::after {
position: absolute;
top: 50%;
right: 100%;
width: 0;
height: 0;
border-width: 8px 8px 8px 0;
border-style: solid;
margin-top: -8px;
content: '';
}
&::before {
border-color: transparent $gb-light-gray-500;
margin-right: 1px;
}
&::after {
border-color: transparent $color-white;
}
}
}
.cf-complex__inserter-item {
.block-editor & {
color: $gb-dark-gray-600;
&:hover {
color: $gb-dark-gray-900;
}
}
}
.cf-complex__inserter-button {
.edit-post-sidebar .cf-complex__tabs & {
border-color: $gb-dark-gray-150;
}
}
.cf-complex__tabs-item {
.block-editor & {
color: $gb-dark-gray-500;
}
.block-editor &--current {
color: $gb-dark-gray-900;
}
.edit-post-sidebar & {
border-color: $gb-dark-gray-150;
}
}
.cf-complex__group-head {
.edit-post-sidebar & {
border-color: $gb-dark-gray-150;
}
}
.cf-complex__group-body {
.edit-post-sidebar & {
display: block;
border-color: $gb-dark-gray-150;
}
}
.cf-complex__group-actions {
.edit-post-sidebar &--tabbed {
border-color: $gb-dark-gray-150;
}
}

View file

@ -0,0 +1,96 @@
/**
* External dependencies.
*/
import { format } from '@wordpress/date';
import { Component } from '@wordpress/element';
import { addFilter } from '@wordpress/hooks';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies.
*/
import './style.scss';
class DateTimeField extends Component {
/**
* Handles the change.
*
* @param {Date[]} selectedDates
* @param {string} selectedDateStr
* @return {void}
*/
handleChange = ( selectedDates, selectedDateStr ) => {
const {
id,
onChange,
value,
field
} = this.props;
const formattedDate = format( field.storage_format, selectedDateStr );
if ( formattedDate !== value ) {
onChange( id, formattedDate );
}
}
/**
* Renders the component.
*
* @return {Object}
*/
render() {
const { handleChange } = this;
const { children } = this.props;
return children( {
handleChange
} );
}
}
addFilter( 'carbon-fields.date_time.block', 'carbon-fields/blocks', ( OriginalDateTimeField ) => ( props ) => {
return (
<DateTimeField { ...props }>
{ ( {
handleChange
} ) => (
<OriginalDateTimeField
buttonText={ __( 'Select Date', 'carbon-fields-ui' ) }
{ ...props }
onChange={ handleChange }
/>
) }
</DateTimeField>
);
} );
addFilter( 'carbon-fields.date.block', 'carbon-fields/blocks', ( OriginalDateField ) => ( props ) => {
return (
<DateTimeField { ...props }>
{ ( {
handleChange
} ) => (
<OriginalDateField
{ ...props }
onChange={ handleChange }
/>
) }
</DateTimeField>
);
} );
addFilter( 'carbon-fields.time.block', 'carbon-fields/blocks', ( OriginalTimeField ) => ( props ) => {
return (
<DateTimeField { ...props }>
{ ( {
handleChange
} ) => (
<OriginalTimeField
{ ...props }
onChange={ handleChange }
/>
) }
</DateTimeField>
);
} );

View file

@ -0,0 +1,44 @@
/* ==========================================================================
DateTime
========================================================================== */
.cf-datetime__inner {
.edit-post-sidebar & {
position: relative;
}
.edit-post-sidebar &::before {
position: absolute;
top: 50%;
left: 9px;
display: inline-block;
margin-top: -10px;
}
}
.cf-datetime__input {
.wp-block .cf-field & {
min-width: 0;
border-color: $wp-color-gray-light-500;
border-radius: 0;
&:focus {
box-shadow: none;
}
}
.edit-post-sidebar .cf-field & {
padding-left: 35px;
}
}
.cf-datetime__button {
.wp-block & {
height: auto;
border-color: $wp-color-gray-light-500;
}
.edit-post-sidebar & {
display: none;
}
}

View file

@ -0,0 +1,21 @@
/**
* External dependencies.
*/
import { addFilter } from '@wordpress/hooks';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies.
*/
import './style.scss';
addFilter( 'carbon-fields.file.block', 'carbon-fields/blocks', ( OriginalFileField ) => ( props ) => {
return (
<OriginalFileField
buttonLabel={ __( 'Select File', 'carbon-fields-ui' ) }
mediaLibraryButtonLabel={ __( 'Use File', 'carbon-fields-ui' ) }
mediaLibraryTitle={ __( 'Select File', 'carbon-fields-ui' ) }
{ ...props }
/>
);
} );

View file

@ -0,0 +1,21 @@
/* ==========================================================================
File
========================================================================== */
.cf-file__inner {
.edit-post-sidebar & {
border-color: $gb-dark-gray-150;
}
}
.cf-file__content {
.edit-post-sidebar & {
border-color: $gb-dark-gray-150;
}
}
.cf-file__name {
.edit-post-sidebar & {
border-color: $gb-dark-gray-150;
}
}

View file

@ -0,0 +1,11 @@
/**
* External dependencies.
*/
import { addFilter } from '@wordpress/hooks';
/**
* The internal dependencies.
*/
import NotSupportedField from '../../components/not-supported-field';
addFilter( 'carbon-fields.footer_scripts.block', 'carbon-fields/blocks', () => ( props ) => <NotSupportedField type={ props.field.type } /> );

View file

@ -0,0 +1,11 @@
/**
* External dependencies.
*/
import { addFilter } from '@wordpress/hooks';
/**
* The internal dependencies.
*/
import NotSupportedField from '../../components/not-supported-field';
addFilter( 'carbon-fields.header_scripts.block', 'carbon-fields/blocks', () => ( props ) => <NotSupportedField type={ props.field.type } /> );

View file

@ -0,0 +1,11 @@
/**
* External dependencies.
*/
import { addFilter } from '@wordpress/hooks';
/**
* The internal dependencies.
*/
import NotSupportedField from '../../components/not-supported-field';
addFilter( 'carbon-fields.hidden.block', 'carbon-fields/blocks', () => ( props ) => <NotSupportedField type={ props.field.type } /> );

View file

@ -0,0 +1,16 @@
/**
* External dependencies.
*/
import { addFilter } from '@wordpress/hooks';
import { __ } from '@wordpress/i18n';
addFilter( 'carbon-fields.image.block', 'carbon-fields/blocks', ( OriginalImageField ) => ( props ) => {
return (
<OriginalImageField
buttonLabel={ __( 'Select Image', 'carbon-fields-ui' ) }
mediaLibraryButtonLabel={ __( 'Use Image', 'carbon-fields-ui' ) }
mediaLibraryTitle={ __( 'Select Image', 'carbon-fields-ui' ) }
{ ...props }
/>
);
} );

View file

@ -0,0 +1,63 @@
/**
* External dependencies.
*/
import { compose } from '@wordpress/compose';
import { addFilter } from '@wordpress/hooks';
import { withDispatch } from '@wordpress/data';
/**
* Carbon Fields dependencies.
*/
import { withValidation } from '@carbon-fields/core';
/**
* Internal dependencies.
*/
import withConditionalLogic from '../hocs/with-conditional-logic';
import isGutenberg from '../../metaboxes/utils/is-gutenberg';
/**
* Connects every field to the store.
*/
addFilter( 'carbon-fields.field-edit.block', 'carbon-fields/blocks', compose(
withConditionalLogic,
withDispatch( ( dispatch ) => {
// Widgets support - WordPress 5.8
if ( isGutenberg() ) {
const { lockPostSaving, unlockPostSaving } = dispatch( 'core/editor' );
return {
lockSaving: lockPostSaving,
unlockSaving: unlockPostSaving
};
}
return {};
} ),
withValidation
) );
/**
* Internal dependencies.
*/
import './association';
import './complex';
import './datetime';
import './file';
import './footer-scripts';
import './header-scripts';
import './hidden';
import './image';
import './map';
import './multiselect';
import './media-gallery';
import './oembed';
import './radio';
import './radio-image';
import './select';
import './set';
import './sidebar';
import './separator';
import './text';
import './textarea';
import './block-preview';

View file

@ -0,0 +1,4 @@
/**
* Internal dependencies.
*/
import './style.scss';

View file

@ -0,0 +1,15 @@
/* ==========================================================================
Map
========================================================================== */
.cf-map__canvas {
.edit-post-sidebar & {
border-color: $gb-dark-gray-150;
border-top-width: 1px;
margin-top: $size-base;
}
.block-editor .cf-map__search:focus-within ~ & {
border-color: $wp-color-medium-blue;
}
}

View file

@ -0,0 +1,21 @@
/**
* External dependencies.
*/
import { addFilter } from '@wordpress/hooks';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies.
*/
import './style.scss';
addFilter( 'carbon-fields.media_gallery.block', 'carbon-fields/blocks', ( OriginalMediaGalleryField ) => ( props ) => {
return (
<OriginalMediaGalleryField
buttonLabel={ __( 'Select Attachments', 'carbon-fields-ui' ) }
mediaLibraryButtonLabel={ __( 'Use Attachments', 'carbon-fields-ui' ) }
mediaLibraryTitle={ __( 'Select Attachments', 'carbon-fields-ui' ) }
{ ...props }
/>
);
} );

View file

@ -0,0 +1,69 @@
/* ==========================================================================
Media Gallery
========================================================================== */
.cf-media-gallery__item {
.block-editor & {
@media (min-width: 1440px) {
flex-basis: 16.6667%;
}
@media (min-width: 1680px) {
flex-basis: 16.6667%;
}
}
.edit-post-sidebar & {
flex-basis: 50%;
}
}
.cf-media-gallery__inner {
.wp-block & {
border: 1px solid $wp-color-gray-light-500;
}
.edit-post-sidebar & {
border: 1px solid $gb-dark-gray-150;
}
}
.cf-media-gallery__actions {
.wp-block & {
border-top: 1px solid $wp-color-gray-light-500;
}
.edit-post-sidebar & {
border-top: 1px solid $gb-dark-gray-150;
}
}
.cf-media-gallery__item-inner {
.wp-block & {
border: 1px solid $wp-color-gray-light-500;
}
.edit-post-sidebar & {
border: 1px solid $gb-dark-gray-150;
}
}
.cf-media-gallery__item-preview {
.block-editor & {
background-color: $wp-color-gray-light-200;
}
}
.cf-media-gallery__item-name {
.block-editor & {
background-color: $wp-color-gray-light-200;
}
.wp-block & {
border-top: 1px solid $wp-color-gray-light-500;
}
.edit-post-sidebar & {
border-top: 1px solid $gb-dark-gray-150;
}
}

View file

@ -0,0 +1,4 @@
/**
* Internal dependencies.
*/
import './style.scss';

View file

@ -0,0 +1,20 @@
/* ==========================================================================
Multiselect
========================================================================== */
.cf-multiselect__control {
.wp-block & {
border-radius: 0;
}
.edit-post-sidebar &,
.edit-post-sidebar &:hover {
border-color: $gb-dark-gray-150;
}
.edit-post-sidebar &--is-focused,
.edit-post-sidebar &--is-focused:hover {
border-color: $wp-color-medium-blue;
box-shadow: 0 0 0 1px $wp-color-medium-blue;
}
}

View file

@ -0,0 +1,4 @@
/**
* Internal dependencies.
*/
import './style.scss';

View file

@ -0,0 +1,16 @@
/* ==========================================================================
oEmbed
========================================================================== */
.cf-oembed__preview {
.edit-post-sidebar & {
border-color: $gb-dark-gray-150;
border-top-width: 1px;
margin-top: $size-base;
}
.block-editor .cf-oembed:focus-within & {
border-color: $wp-color-medium-blue;
}
}

View file

@ -0,0 +1,4 @@
/**
* Internal dependencies.
*/
import './style.scss';

View file

@ -0,0 +1,38 @@
/* ==========================================================================
Radio Image
========================================================================== */
.cf-radio__list-item {
.block-editor & {
margin: 0;
}
.wp-block .cf-radio-image & {
@media (min-width: 1440px) {
flex-basis: 25%;
}
@media (min-width: 1680px) {
flex-basis: 25%;
}
}
.edit-post-sidebar .cf-radio-image & {
flex-basis: 33.3333%;
}
}
.cf-radio-image__image {
.wp-block & {
border: 1px solid $wp-color-gray-light-500;
}
.edit-post-sidebar & {
border: 1px solid $gb-dark-gray-150;
}
.block-editor .cf-radio__input:focus ~ .cf-radio__label &,
.block-editor .cf-radio__input:checked ~ .cf-radio__label & {
outline: 2px solid $wp-color-medium-blue;
}
}

View file

@ -0,0 +1,4 @@
/**
* Internal dependencies.
*/
import './style.scss';

View file

@ -0,0 +1,9 @@
/* ==========================================================================
Radio
========================================================================== */
.cf-radio__list {
.wp-block & {
list-style: none outside none;
}
}

View file

@ -0,0 +1,4 @@
/**
* Internal dependencies.
*/
import './style.scss';

View file

@ -0,0 +1,14 @@
/* ==========================================================================
Select
========================================================================== */
.cf-select__input {
.wp-block .cf-field & {
border-color: $wp-color-gray-light-500;
border-radius: 0;
&:focus {
box-shadow: none;
}
}
}

View file

@ -0,0 +1,4 @@
/**
* Internal dependencies.
*/
import './style.scss';

View file

@ -0,0 +1,9 @@
/* ==========================================================================
Separator
========================================================================== */
.cf-separator {
.block-editor & h3 {
margin: 0;
}
}

View file

@ -0,0 +1,4 @@
/**
* Internal dependencies.
*/
import './style.scss';

View file

@ -0,0 +1,9 @@
/* ==========================================================================
Set
========================================================================== */
.cf-set__list {
.wp-block & {
list-style: none outside none;
}
}

View file

@ -0,0 +1,11 @@
/**
* External dependencies.
*/
import { addFilter } from '@wordpress/hooks';
/**
* The internal dependencies.
*/
import NotSupportedField from '../../components/not-supported-field';
addFilter( 'carbon-fields.sidebar.block', 'carbon-fields/blocks', () => ( props ) => <NotSupportedField type={ props.field.type } /> );

View file

@ -0,0 +1,4 @@
/**
* Internal dependencies.
*/
import './style.scss';

View file

@ -0,0 +1,14 @@
/* ==========================================================================
Text
========================================================================== */
.cf-text__input {
.wp-block .cf-field & {
border-color: $wp-color-gray-light-500;
border-radius: 0;
&:focus {
box-shadow: none;
}
}
}

View file

@ -0,0 +1,4 @@
/**
* Internal dependencies.
*/
import './style.scss';

View file

@ -0,0 +1,16 @@
/* ==========================================================================
Textarea
========================================================================== */
.cf-textarea__input {
.wp-block .cf-field & {
border-color: $wp-color-gray-light-500;
border-radius: 0;
transition: border-color $transition-base;
&:focus {
border-color: $wp-color-medium-blue;
box-shadow: none;
}
}
}

View file

@ -0,0 +1,131 @@
/**
* External dependencies.
*/
import distinctUntilChanged from 'callbag-distinct-until-changed';
import { select } from '@wordpress/data';
import { pipe, map } from 'callbag-basics';
import {
get,
has,
omit,
assign,
repeat,
mapKeys,
findIndex,
startsWith
} from 'lodash';
/**
* Carbon Fields dependencies.
*/
import { fromSelector, withConditionalLogic } from '@carbon-fields/core';
/**
* Adds the `parent.` parent prefix to field's name.
*
* @param {Object[]} fields
* @param {number} depth
* @return {Array[]}
*/
function mapParentPrefix( fields, depth = 0 ) {
return mapKeys( fields, ( value, key ) => `${ repeat( 'parent.', depth ) }${ key }` );
}
/**
* Returns whether the given string is a group identifier.
*
* @param {string} id
* @return {boolean}
*/
function isComplexGroupIdentifier( id ) {
return startsWith( id, 'cf-' );
}
/**
* The function used to track dependencies required
* by conditional logic.
*
* @param {Object} props
* @return {Object}
*/
function input( props ) {
return pipe(
fromSelector( select( 'core/block-editor' ).getBlock, props.blockId ),
distinctUntilChanged(),
map( ( blockData ) => blockData?.attributes?.data )
);
}
/**
* The function that provides the data that needs to be
* evaluated by conditional logic.
*
* @param {Object} props
* @param {Object} fields
* @return {Object}
*/
function output( props, fields ) {
const isTopLevelField = has( fields, props.field.base_name );
let siblingFields = {};
if ( isTopLevelField ) {
siblingFields = mapParentPrefix( omit( fields, [ props.field.base_name ] ) );
} else {
// Get the hierarchy.
const path = props.id.split( '__' );
// Remove the chunk with identifier of block since
// we already have it.
path.shift();
// Remove the chunk with name of root field.
const rootFieldName = path.shift();
// Remove the chunk with name of field since
// we already have it.
path.pop();
// Keep reference to the depth
// so we can add the `parent.` prefix.
let depth = path.reduce( ( accumulator, chunk ) => {
return isComplexGroupIdentifier( chunk )
? accumulator
: accumulator + 1;
}, 0 );
// Collect fields that are siblings of root field.
siblingFields = omit( fields, [ rootFieldName ] );
siblingFields = mapParentPrefix( siblingFields, depth + 1 );
// Keep reference to the full path of the field.
let pathPrefix = rootFieldName;
while ( path.length > 0 ) {
const chunk = path.shift();
const isGroup = isComplexGroupIdentifier( chunk );
const isNestedComplex = ! isGroup;
if ( isGroup ) {
const groupIndex = findIndex( get( fields, pathPrefix ), [ '_id', chunk ] );
pathPrefix = `${ pathPrefix }.${ groupIndex }`;
let groupFields = get( fields, pathPrefix );
groupFields = omit( groupFields, [ '_id', '_type', props.field.base_name ] );
groupFields = mapParentPrefix( groupFields, depth );
assign( siblingFields, groupFields );
}
if ( isNestedComplex ) {
pathPrefix = `${ pathPrefix }.${ chunk }`;
depth--;
}
}
}
return siblingFields;
}
export default withConditionalLogic( input, output );

View file

@ -0,0 +1,75 @@
/* eslint no-console: [ 'error', { allow: [ 'error' ] } ] */
/**
* External dependencies.
*/
import { dispatch } from '@wordpress/data';
import { registerBlockType } from '@wordpress/blocks';
import { setLocaleData } from '@wordpress/i18n';
import {
get,
kebabCase,
isPlainObject
} from 'lodash';
/**
* Internal dependencies.
*/
import './fields';
import './store';
import BlockEdit from './components/block-edit';
import BlockSave from './components/block-save';
import transformFieldsToAttributes from './utils/transform-fields-to-attributes';
/**
* Sets the locale data for the package type
*/
setLocaleData( window.cf.config.locale, 'carbon-fields-ui' );
/**
* Register the blocks.
*/
const containerDefinitions = {};
const fieldDefinitions = {};
get( window.cf, 'preloaded.blocks', [] ).forEach( ( container ) => {
const name = kebabCase( container.id ).replace( 'carbon-fields-container-', '' );
const fields = transformFieldsToAttributes( container.fields );
const getBlockSetting = ( key, def = null ) => get( container, `settings.${ key }`, def );
containerDefinitions[ name ] = container;
fieldDefinitions[ name ] = container.fields.map( ( field ) => ( { ...field } ) );
registerBlockType( `carbon-fields/${ name }`, {
title: container.title,
icon: getBlockSetting( 'icon' ),
parent: getBlockSetting( 'parent', [] ),
category: getBlockSetting( 'category.slug' ),
keywords: getBlockSetting( 'keywords', [] ),
description: getBlockSetting( 'description', '' ),
attributes: {
data: {
type: 'object',
default: fields
}
},
supports: {
tabs: isPlainObject( getBlockSetting( 'tabs' ) ),
preview: getBlockSetting( 'preview' ),
innerBlocks: getBlockSetting( 'inner_blocks.enabled' ),
alignWide: false,
anchor: false,
html: false
},
edit: BlockEdit,
save: BlockSave,
example: true,
} );
} );
/**
* Load the definitions in store.
*/
dispatch( 'carbon-fields/blocks' ).setupContainerDefinitions( containerDefinitions );
dispatch( 'carbon-fields/blocks' ).setupFieldDefinitions( fieldDefinitions );

View file

@ -0,0 +1,29 @@
/**
* Returns an action object used to setup the definitions state when first opening an editor.
*
* @param {Object} definitions
* @return {Object}
*/
export function setupContainerDefinitions( definitions ) {
return {
type: 'SETUP_CONTAINER_DEFINITIONS',
payload: {
definitions
}
};
}
/**
* Returns an action object used to setup the definitions state when first opening an editor.
*
* @param {Object} definitions
* @return {Object}
*/
export function setupFieldDefinitions( definitions ) {
return {
type: 'SETUP_FIELD_DEFINITIONS',
payload: {
definitions
}
};
}

View file

@ -0,0 +1,20 @@
/**
* External dependencies.
*/
import { registerStore } from '@wordpress/data';
/**
* Internal dependencies.
*/
import reducer from './reducer';
import * as actions from './actions';
import * as selectors from './selectors';
/**
* Register the store.
*/
registerStore( 'carbon-fields/blocks', {
reducer,
actions,
selectors
} );

View file

@ -0,0 +1,43 @@
/**
* External dependencies.
*/
import { combineReducers } from '@wordpress/data';
/**
* The reducer that keeps track of container definitions keyed by block's name.
*
* @param {Object} state
* @param {Object} action
* @return {Object}
*/
export function containerDefinitionsByBlockName( state = {}, action ) {
switch ( action.type ) {
case 'SETUP_CONTAINER_DEFINITIONS':
return action.payload.definitions;
default:
return state;
}
}
/**
* The reducer that keeps track of field definitions keyed by block's name.
*
* @param {Object} state
* @param {Object} action
* @return {Object}
*/
export function fieldDefinitionsByBlockName( state = {}, action ) {
switch ( action.type ) {
case 'SETUP_FIELD_DEFINITIONS':
return action.payload.definitions;
default:
return state;
}
}
export default combineReducers( {
containerDefinitionsByBlockName,
fieldDefinitionsByBlockName
} );

View file

@ -0,0 +1,25 @@
/**
* Get the container by a given block name.
*
* @param {Object} state
* @param {string} blockName
* @return {Object}
*/
export function getContainerDefinitionByBlockName( state, blockName ) {
return state.containerDefinitionsByBlockName[
blockName.replace( 'carbon-fields/', '' )
] || {};
}
/**
* Get the fields by a given block name.
*
* @param {Object} state
* @param {string} blockName
* @return {Object[]}
*/
export function getFieldDefinitionsByBlockName( state, blockName ) {
return state.fieldDefinitionsByBlockName[
blockName.replace( 'carbon-fields/', '' )
] || [];
}

View file

@ -0,0 +1,14 @@
/**
* Transforms the fields to an attributes set.
*
* @param {Object[]} fields
* @return {Object}
*/
export default function transformFieldsToAttributes( fields ) {
return fields.reduce( ( attributes, field ) => {
return {
...attributes,
[ field.base_name ]: field.default_value
};
}, {} );
}

View file

@ -0,0 +1,6 @@
/**
* External dependencies.
*/
import { createContext } from '@wordpress/element';
export const { Provider, Consumer } = createContext( false );

View file

@ -0,0 +1,120 @@
/**
* External dependencies
*/
import { Component, createRef } from '@wordpress/element';
import { includes, debounce } from 'lodash';
/**
* Internal dependencies.
*/
import { Provider, Consumer } from './context';
/**
* Names of control nodes which qualify for disabled behavior.
*
* See WHATWG HTML Standard: 4.10.18.5: "Enabling and disabling form controls: the disabled attribute".
*
* @link https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#enabling-and-disabling-form-controls:-the-disabled-attribute
*
* @type {string[]}
*/
const DISABLED_ELIGIBLE_NODE_NAMES = [
'BUTTON',
'FIELDSET',
'INPUT',
'OPTGROUP',
'OPTION',
'SELECT',
'TEXTAREA'
];
/**
* Disables descendant tabbable elements and prevents pointer interaction.
*
* @borrows https://github.com/WordPress/gutenberg/blob/master/packages/components/src/disabled/index.js
*/
class Disabled extends Component {
/**
* Keeps reference to the DOM node.
*/
node = createRef();
/**
* Lifecycle hook.
*
* @return {void}
*/
componentDidMount() {
this.disable();
this.observer = new window.MutationObserver( this.disable );
this.observer.observe( this.node.current, {
childList: true,
attributes: true,
subtree: true
} );
}
/**
* Lifecycle hook.
*
* @return {void}
*/
componentWillUnmount() {
this.observer.disconnect();
this.disable.cancel();
}
/**
* Disables all elements.
*
* @return {void}
*/
disable = debounce( () => {
this.node.current.querySelectorAll( `
[tabindex],
button:not([disabled]),
input:not([type="hidden"]):not([disabled]),
select:not([disabled]),
textarea:not([disabled]),
iframe,
object,
embed,
[contenteditable]:not([contenteditable=false])
` ).forEach( ( node ) => {
if ( includes( DISABLED_ELIGIBLE_NODE_NAMES, node.nodeName ) ) {
node.setAttribute( 'disabled', '' );
}
if ( node.hasAttribute( 'tabindex' ) ) {
node.removeAttribute( 'tabindex' );
}
if ( node.hasAttribute( 'contenteditable' ) ) {
node.setAttribute( 'contenteditable', 'false' );
}
} );
}, { leading: true } )
/**
* Renders the component.
*
* @return {Objec}
*/
render() {
const { className, children } = this.props;
return (
<Provider value={ true }>
<div ref={ this.node } className={ className }>
{ children }
</div>
</Provider>
);
}
}
Disabled.Consumer = Consumer;
export default Disabled;

View file

@ -0,0 +1,106 @@
/**
* External dependencies.
*/
import cx from 'classnames';
import { compose } from '@wordpress/compose';
import { withSelect } from '@wordpress/data';
import { kebabCase } from 'lodash';
/**
* Internal dependencies.
*/
import './style.scss';
import Disabled from '../../components/disabled';
import withFilters from '../../hocs/with-filters';
/**
* Renders the base wrapper of the field.
*
* @param {Object} props
* @param {string} props.id
* @param {Object} props.field
* @param {?string} props.error
* @param {boolean} props.hidden
* @param {string} props.className
* @param {mixed} props.children
* @return {Object}
*/
function Field( {
id,
field,
error,
hidden,
className,
children
} ) {
const styles = !! field.width ? { flexBasis: `${ field.width }%` } : null;
const classes = [
'cf-field',
`cf-${ kebabCase( field.type ) }`,
{
'cf-field--has-width': !! field.width,
'cf-field--invalid': !! error
},
className,
...field.classes
];
if ( field.hidden ) {
return ( null );
}
return (
<div
className={ cx( classes ) }
style={ styles }
hidden={ hidden }
>
<div className="cf-field__head">
{ field.label && (
<label className="cf-field__label" htmlFor={ id }>
{ field.label }
{ field.required && (
<span className="cf-field__asterisk">*</span>
) }
</label>
) }
</div>
{ ! hidden && (
<div className="cf-field__body">
{ children }
</div>
) }
{ hidden && (
<Disabled className="cf-field__body">
{ children }
</Disabled>
) }
{ field.help_text && (
<em className="cf-field__help" dangerouslySetInnerHTML={ { __html: field.help_text } }></em>
) }
{ error && (
<span className="cf-field__error">
{ error }
</span>
) }
</div>
);
}
export default compose(
withSelect( ( select, props ) => {
const { getValidationError, isFieldVisible } = select( 'carbon-fields/core' );
return {
error: getValidationError( props.id ),
hidden: ! isFieldVisible( props.id )
};
} ),
withFilters( 'carbon-fields.field-wrapper' )
)( Field );

View file

@ -0,0 +1,52 @@
/* ==========================================================================
Field
========================================================================== */
.cf-field,
.cf-field__head,
.cf-field__body {
box-sizing: border-box;
flex: 1 1 100%;
}
.cf-field {
@media (max-width: 1024px) {
flex-basis: 100% !important;
}
// Show only on block preview iframe
&.cf-block-preview {
display: none;
}
}
.cf-field__body {
.cf-rich-text & {
box-sizing: content-box;
}
.cf-complex--tabbed-vertical > & {
display: flex;
align-items: flex-start;
}
}
.cf-field__label {
display: block;
.cf-html &,
.cf-separator &,
.cf-block-preview & {
display: none;
}
}
.cf-field__asterisk {
color: $wp-color-accent-red;
}
.cf-field__error {
display: block;
margin-top: $size-base;
color: $wp-color-accent-red;
}

View file

@ -0,0 +1,112 @@
/**
* External dependencies.
*/
import { compose } from '@wordpress/compose';
import { withEffects, toProps } from 'refract-callbag';
import {
map,
pipe,
merge
} from 'callbag-basics';
import of from 'callbag-of';
function MediaLibrary( { children, openMediaBrowser } ) {
return children( { openMediaBrowser } );
}
/**
* The function that controls the stream of side-effects.
*
* @param {Object} component
* @return {Object}
*/
function aperture( component ) {
const mount$ = component.mount;
const unmount$ = component.unmount;
const [ openMediaBrowserEvent$, openMediaBrowser ] = component.useEvent( 'openMediaBrowserEvent' );
return merge(
pipe(
mount$,
map( () => ( {
type: 'INIT'
} ) )
),
pipe(
unmount$,
map( () => ( {
type: 'DESTROY'
} ) )
),
pipe(
of( {
openMediaBrowser
} ),
map( toProps )
),
pipe(
openMediaBrowserEvent$,
map( ( payload ) => ( {
type: 'OPEN',
payload
} ) )
)
);
}
/**
* The function that causes the side effects.
*
* @param {Object} props
* @return {Function}
*/
function handler( props ) {
let mediaBrowser = null;
return function( effect ) {
switch ( effect.type ) {
case 'INIT':
const { onSelect, typeFilter } = props;
mediaBrowser = wp.media( {
title: props.title,
library: {
type: typeFilter
},
button: {
text: props.buttonLabel
},
multiple: props.multiple
} );
mediaBrowser.on( 'select', () => {
const file = mediaBrowser.state()
.get( 'selection' )
.toJSON();
onSelect( file );
} );
break;
case 'OPEN':
if ( mediaBrowser ) {
mediaBrowser.open();
}
break;
case 'DESTROY':
mediaBrowser = null;
break;
}
};
}
const applyWithEffects = withEffects( aperture, { handler } );
export default compose(
applyWithEffects
)( MediaLibrary );

View file

@ -0,0 +1,18 @@
/**
* The external dependencies.
*/
import { __ } from '@wordpress/i18n';
/**
* Render a notice to inform the user that the field doesn't have
* any options.
*
* @return {React.Element}
*/
const NoOptions = () => (
<em>
{ __( 'No options.', 'carbon-fields-ui' ) }
</em>
);
export default NoOptions;

View file

@ -0,0 +1,87 @@
/**
* External dependencies.
*/
import cx from 'classnames';
import { __ } from '@wordpress/i18n';
import { Component } from '@wordpress/element';
import { omit } from 'lodash';
/**
* Internal dependencies.
*/
import './style.scss';
/**
* The keycode used to represent the "Enter" key.
*
* @type {number}
*/
const KEY_ENTER = 13;
class SearchInput extends Component {
/**
* Default properties.
*
* TODO: Use `@wordpress/i18n` for translations.
*
* @type {Object}
*/
static defaultProps = {
placeholder: __( 'Search...', 'carbon-fields-ui' )
};
/**
* Handles change event of input.
*
* @param {Object} e
* @return {void}
*/
handleChange = ( e ) => {
this.props.onChange( e.target.value );
}
/**
* Handles keydown event of input.
*
* @param {Object} e
* @return {void}
*/
handleKeyDown = ( e ) => {
if ( e.keyCode === KEY_ENTER ) {
e.preventDefault();
this.props.onChange( e.target.value );
}
}
/**
* Renders the component.
*
* @return {Object}
*/
render() {
const {
value,
className,
...props
} = this.props;
return (
<div className={ cx( 'cf-search-input dashicons-before dashicons-search', className ) }>
<input
type="text"
autoComplete="off"
className="cf-search-input__inner"
defaultValue={ value }
onChange={ this.handleChange }
onKeyDown={ this.handleKeyDown }
{ ...omit( props, [
'onChange'
] ) }
/>
</div>
);
}
}
export default SearchInput;

View file

@ -0,0 +1,50 @@
/* ==========================================================================
Search Input
========================================================================== */
.cf-search-input {
position: relative;
&::before {
position: absolute;
top: 50%;
left: 9px;
margin-top: -10px;
.postbox & {
color: $wp-color-base-gray;
}
.wp-block & {
color: $gb-dark-gray-500;
}
}
}
.cf-search-input__inner {
display: block;
width: 100%;
margin: 0;
.cf-container &,
.block-editor .cf-field & {
padding-left: 35px;
}
.cf-container & {
padding-top: 8px;
padding-bottom: 8px;
border-color: $wp-color-gray-light-500;
}
.block-editor .cf-container &,
.wp-block .cf-field &,
.edit-post-sidebar .cf-block__fields & {
border-radius: 0;
border: 0;
&:focus {
box-shadow: none;
}
}
}

View file

@ -0,0 +1,107 @@
/**
* External dependencies.
*/
import produce from 'immer';
import { Component, Children } from '@wordpress/element';
class Sortable extends Component {
/**
* Lifecycle hook.
*
* @return {void}
*/
componentDidMount() {
const { options, forwardedRef } = this.props;
window.jQuery( forwardedRef.current ).sortable( {
...options,
start: this.handleStart,
update: this.handleUpdate,
stop: this.handleStop
} );
}
/**
* Lifecycle hook.
*
* @return {void}
*/
componentWillUnmount() {
const { forwardedRef } = this.props;
const $element = window.jQuery( forwardedRef.current );
const instance = $element.sortable( 'instance' );
if ( instance ) {
$element.sortable( 'destroy' );
}
}
/**
* Handles the `start` event.
*
* @param {Object} e
* @param {Object} ui
* @return {void}
*/
handleStart = ( e, ui ) => {
const { onStart } = this.props;
if ( onStart ) {
onStart( e, ui );
}
ui.item.data( 'index', ui.item.index() );
}
/**
* Handles the `update` event.
*
* @param {Object} e
* @param {Object} ui
* @return {void}
*/
handleUpdate = ( e, ui ) => {
const {
items,
forwardedRef,
onUpdate
} = this.props;
const oldIndex = ui.item.data( 'index' );
const newIndex = ui.item.index();
ui.item.removeData( 'index' );
window.jQuery( forwardedRef.current ).sortable( 'cancel' );
onUpdate( produce( items, ( draft ) => {
draft.splice( newIndex, 0, ...draft.splice( oldIndex, 1 ) );
} ) );
}
/**
* Handles the `stop` event.
*
* @param {Object} e
* @param {Object} ui
* @return {void}
*/
handleStop = ( e, ui ) => {
const { onStop } = this.props;
if ( onStop ) {
onStop( e, ui );
}
}
/**
* Renders the component.
*
* @return {Object}
*/
render() {
return Children.only( this.props.children );
}
}
export default Sortable;

View file

@ -0,0 +1,532 @@
/**
* External dependencies.
*/
import produce from 'immer';
import { __, sprintf } from '@wordpress/i18n';
import {
Component,
Fragment,
createRef
} from '@wordpress/element';
import { compose, withState } from '@wordpress/compose';
import { addFilter } from '@wordpress/hooks';
import { withEffects, toProps } from 'refract-callbag';
import cx from 'classnames';
import {
find,
pick,
without,
isMatch,
isEmpty,
debounce
} from 'lodash';
import {
combine,
map,
merge,
pipe
} from 'callbag-basics';
import of from 'callbag-of';
/**
* Internal dependencies.
*/
import './style.scss';
import SearchInput from '../../components/search-input';
import Sortable from '../../components/sortable';
import apiFetch from '../../utils/api-fetch';
class AssociationField extends Component {
/**
* Keeps reference to the DOM node that contains the selected items.
*
* @type {Object}
*/
selectedList = createRef();
/**
* Keeps reference to the DOM bnode that contains the options.
*
* @type {Object}
*/
sourceList = createRef();
/**
* Lifecycle hook.
*
* @return {void}
*/
componentDidMount() {
const {
fetchSelectedOptions,
field,
value,
setState
} = this.props;
setState( {
options: field.options.options,
totalOptionsCount: field.options.total_options
} );
if ( value.length ) {
fetchSelectedOptions();
}
this.sourceList.current.addEventListener( 'scroll', this.handleSourceListScroll );
}
/**
* Lifecycle hook.
*
* @return {void}
*/
componentWillUnmount() {
this.sourceList.current.removeEventListener( 'scroll', this.handleSourceListScroll );
}
/**
* Handles the scroll event of the source list.
*
* @return {void}
*/
handleSourceListScroll = () => {
const {
fetchOptions,
setState,
options,
page,
queryTerm
} = this.props;
const sourceList = this.sourceList.current;
if ( sourceList.offsetHeight + sourceList.scrollTop === sourceList.scrollHeight ) {
setState( {
page: page + 1
} );
fetchOptions( {
type: 'append',
options: options,
queryTerm,
page: page + 1
} );
}
}
/**
* Handles the change of search.
*
* @param {string} queryTerm
* @return {void}
*/
handleSearchChange = debounce( ( queryTerm ) => {
const {
fetchOptions,
setState
} = this.props;
setState( {
page: 1,
queryTerm
} );
fetchOptions( {
type: 'replace',
page: 1,
queryTerm
} );
}, 250 )
/**
* Handles addition of a new item.
*
* @param {Array} option
* @return {void}
*/
handleAddItem = ( option ) => {
const {
field,
id,
value,
onChange,
setState,
selectedOptions
} = this.props;
// Don't do anything if the duplicates aren't allowed and
// the item is already selected.
if ( ! field.duplicates_allowed && option.disabled ) {
return;
}
// Don't do anything, because the maximum is reached.
if ( field.max > 0 && value.length >= field.max ) {
// eslint-disable-next-line no-alert
alert(
sprintf(
__( 'Maximum number of items reached (%s items)', 'carbon-fields-ui' ),
Number( field.max )
)
);
return;
}
onChange( id, [
...value,
pick( option, 'id', 'type', 'subtype' )
] );
setState( {
selectedOptions: [ ...selectedOptions, option ]
} );
}
/**
* Handles addition of a new item.
*
* @param {Array} option
* @return {void}
*/
handleRemoveItem = ( option ) => {
const {
value,
id,
onChange,
setState,
selectedOptions
} = this.props;
onChange( id, without( value, option ) );
setState( {
selectedOptions: without( selectedOptions, option )
} );
}
/**
* Handles sorting of selected options.
*
* @param {Object[]} items
* @return {void}
*/
handleSort = ( items ) => {
const { id, onChange } = this.props;
onChange( id, items );
}
/**
* Render the component.
*
* @return {Object}
*/
render() {
const {
id,
name,
value,
field,
totalOptionsCount,
selectedOptions,
queryTerm,
isLoading
} = this.props;
let { options } = this.props;
if ( ! field.duplicates_allowed ) {
options = produce( options, ( draft ) => {
draft.map( ( option ) => {
option.disabled = !! find( value, ( selectedOption ) => isMatch( selectedOption, {
id: option.id,
type: option.type,
subtype: option.subtype
} ) );
return option;
} );
} );
}
return (
<Fragment>
<div className="cf-association__bar">
<SearchInput
id={ id }
value={ queryTerm }
onChange={ this.handleSearchChange }
/>
{
isLoading
? <span className="cf-association__spinner spinner is-active"></span>
: ''
}
<span className="cf-association__counter">
{ sprintf(
__( 'Showing %1$d of %2$d results', 'carbon-fields-ui' ),
Number( options.length ),
Number( totalOptionsCount )
) }
</span>
</div>
<div className="cf-association__cols">
<div className="cf-association__col" ref={ this.sourceList }>
{
options.map( ( option, index ) => {
return (
<div className={ cx( 'cf-association__option', { 'cf-association__option--selected': option.disabled } ) } key={ index }>
{ option.thumbnail && (
<img className="cf-association__option-thumb" alt={ __( 'Thumbnail', 'carbon-fields-ui' ) } src={ option.thumbnail } />
) }
<div className="cf-association__option-content">
<span className="cf-association__option-title">
<span className="cf-association__option-title-inner">
{ option.title }
</span>
</span>
<span className="cf-association__option-type">
{ option.label }
</span>
</div>
<div className="cf-association__option-actions">
{ option.edit_link && (
<a
className="cf-association__option-action cf-association__option-action--edit dashicons dashicons-edit"
href={ option.edit_link.replace( '&amp;', '&', 'g' ) }
target="_blank"
rel="noopener noreferrer"
aria-label={ __( 'Edit', 'carbon-fields-ui' ) }
></a>
) }
{ (
! option.disabled
&& ( field.max < 0 || value.length < field.max )
) && (
<button type="button" className="cf-association__option-action dashicons dashicons-plus-alt" aria-label={ __( 'Add', 'carbon-fields-ui' ) } onClick={ () => this.handleAddItem( option ) }>
</button>
) }
</div>
</div>
);
} )
}
</div>
<Sortable
forwardedRef={ this.selectedList }
items={ value }
options={ {
axis: 'y',
forceHelperSize: true,
forcePlaceholderSize: true,
scroll: true,
handle: '.cf-association__option-sort'
} }
onUpdate={ this.handleSort }
>
<div className="cf-association__col" ref={ this.selectedList }>
{
!! selectedOptions.length && value.map( ( option, index ) => {
const optionData = selectedOptions.find( ( selectedOption ) => {
return selectedOption.id === option.id
&& selectedOption.type === option.type
&& selectedOption.subtype === option.subtype;
} );
return (
<div className="cf-association__option" key={ index }>
<span className="cf-association__option-sort dashicons dashicons-menu"></span>
{ optionData.thumbnail && (
<img className="cf-association__option-thumb" src={ optionData.thumbnail } />
) }
<div className="cf-association__option-content">
<span className="cf-association__option-title">
<span className="cf-association__option-title-inner">
{ optionData.title }
</span>
</span>
<span className="cf-association__option-type">
{ optionData.type }
</span>
</div>
<div className="cf-association__option-actions">
<button type="button" className="cf-association__option-action dashicons dashicons-dismiss" aria-label={ __( 'Remove', 'carbon-fields-ui' ) } onClick={ () => this.handleRemoveItem( option ) }></button>
</div>
<input
type="hidden"
name={ `${ name }[${ index }]` }
value={ `${ optionData.type }:${ optionData.subtype }:${ optionData.id }` }
readOnly
/>
</div>
);
} )
}
</div>
</Sortable>
</div>
</Fragment>
);
}
}
/**
* The function that controls the stream of side-effects.
*
* @param {Object} component
* @return {Object}
*/
function aperture( component ) {
const actions = [
{ event: 'fetchOptionsEvent', prop: 'fetchOptions', type: 'FETCH_OPTIONS' },
{ event: 'fetchSelectedOptionsEvent', prop: 'fetchSelectedOptions', type: 'FETCH_SELECTED_OPTIONS' }
].map( ( actionData ) => {
const [ actionChannel$, action ] = component.useEvent( actionData.event );
return {
...actionData,
action,
channel$: actionChannel$
};
} );
const combined$ = pipe(
combine( ...actions.map( ( { action, prop } ) => of( {
action,
prop
} ) ) ),
map( ( combinedActions ) => toProps( combinedActions.reduce(
( acc, curr ) => ( {
...acc,
[ curr.prop ]: curr.action
} ), {}
) ) )
);
return merge(
combined$,
...actions.map( ( { channel$, type } ) => pipe(
channel$,
map( ( payload ) => ( {
type,
payload
} ) )
) )
);
}
/**
* The function that causes the side effects.
*
* @param {Object} props
* @return {Function}
*/
function handler( props ) {
return function( effect ) {
const { payload, type } = effect;
const {
setState,
selectedOptions,
hierarchyResolver
} = props;
switch ( type ) {
case 'FETCH_OPTIONS':
setState( {
isLoading: true
} );
// eslint-disable-next-line
const request = apiFetch(
`${ window.wpApiSettings.root }carbon-fields/v1/association/options`,
'get',
{
container_id: props.containerId,
options: props.value.map( ( option ) => `${ option.id }:${ option.type }:${ option.subtype }` ).join( ';' ),
field_id: hierarchyResolver,
term: payload.queryTerm,
page: payload.page || 1
}
);
/* eslint-disable-next-line no-alert */
const errorHandler = () => alert( __( 'An error occurred while trying to fetch association options.', 'carbon-fields-ui' ) );
request.then( ( response ) => {
setState( {
options: payload.type === 'replace' ? response.options : [ ...payload.options, ...response.options ],
totalOptionsCount: response.total_options
} );
} );
request.catch( errorHandler );
request.finally( () => {
setState( {
isLoading: false
} );
} );
break;
case 'FETCH_SELECTED_OPTIONS':
apiFetch(
`${ window.wpApiSettings.root }carbon-fields/v1/association/`,
'get',
{
container_id: props.containerId,
options: props.value.map( ( option ) => `${ option.id }:${ option.type }:${ option.subtype }` ).join( ';' ),
field_id: hierarchyResolver
}
)
.then( ( response ) => {
setState( {
selectedOptions: [ ...selectedOptions, ...response ]
} );
} );
break;
}
};
}
const applyWithState = withState( {
options: [],
selectedOptions: [],
totalOptionsCount: 0,
queryTerm: '',
page: 1,
isLoading: false
} );
const applyWithEffects = withEffects( aperture, { handler } );
addFilter( 'carbon-fields.association.validate', 'carbon-fields/core', ( field, value ) => {
const { min, required } = field;
if ( required && isEmpty( value ) ) {
return __( 'This field is required.', 'carbon-fields-ui' );
}
if ( min > 0 && value.length < min ) {
return sprintf( __( 'Minimum number of items not reached (%s items)', 'carbon-fields-ui' ), [ field.min ] );
}
return null;
} );
export default compose(
applyWithState,
applyWithEffects
)( AssociationField );

View file

@ -0,0 +1,207 @@
/* ==========================================================================
Association
========================================================================== */
.container-carbon_fields_container_word_settings {
min-width: 0;
max-width: 100%;
width: 100%;
}
.cf-container .cf-field {
max-width: 100%;
}
.cf-association__bar {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: row;
border-color: $wp-color-gray-light-500;
border-style: solid;
border-width: 1px;
.cf-search-input {
flex: 1 1 auto;
}
.cf-search-input__inner {
border: 0;
box-shadow: none;
&:focus {
border-color: none;
box-shadow: none;
outline: none;
}
}
&:focus-within {
border-color: #5b9dd9;
box-shadow: 0 0 2px rgba( 30, 140, 190, 0.8 );
outline: 2px solid transparent;
}
}
.cf-association__counter {
font-size: 12px;
color: $wp-color-dark-gray;
pointer-events: none;
margin-right: 10px;
margin-left: 5px;
}
.cf-association__spinner {
float: none;
margin: 0;
margin-left: 5px;
}
.cf-association__cols {
background: #fff;
position: relative;
z-index: 0;
display: flex;
border-width: 0 1px 1px;
border-style: solid;
border-color: $wp-color-gray-light-500;
&::before {
position: absolute;
top: 0;
left: 50%;
width: 1px;
height: 100%;
background-color: $wp-color-gray-light-500;
content: '';
}
}
.cf-association__col {
width: 50%;
max-height: 160px;
overflow-y: auto;
&.ui-sortable .cf-association__option-title {
white-space: nowrap;
text-overflow: ellipsis;
}
.edit-post-sidebar .cf-association__cols & {
width: 100%;
&:first-child {
border-bottom: 3px solid $wp-color-dark-gray;
}
}
}
.cf-association__option {
display: flex;
align-items: center;
padding: 4px 8px;
height: 32px;
box-sizing: border-box;
&--selected {
background-color: $wp-color-gray-light-100;
}
& + & {
border-top: 1px solid $wp-color-gray-light-500;
}
&.ui-sortable-helper {
border-top: 0;
background-color: $wp-color-gray-light-100;
}
}
.cf-association__option-thumb {
flex: none;
display: block;
width: 24px;
height: 24px;
margin-right: 8px;
}
.cf-association__option-content {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
min-width: 0;
margin-right: 8px;
}
.cf-association__option-title {
flex: 1;
position: relative;
margin-right: $size-base;
.cf-association__option--selected & {
color: $wp-color-dark-silver-gray;
}
}
.cf-association__option-title-inner {
position: absolute;
top: 0;
left: 0;
width: 100%;
font-size: $wp-font-size;
line-height: $wp-line-height;
color: $wp-color-base-gray;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transform: translateY(-50%);
}
.cf-association__option-type {
font-size: 9px;
line-height: 1;
text-transform: uppercase;
color: $wp-color-dark-silver-gray;
.edit-post-sidebar .cf-association__col & {
display: none;
}
}
.cf-association__option-actions {
display: flex;
align-items: center;
justify-content: space-between;
button {
margin-left: 8px;
}
}
.cf-association__option-sort {
margin-right: 4px;
color: $wp-color-dark-silver-gray;
cursor: move;
}
.cf-association__option-action {
padding: 0;
border: 0;
outline: none;
color: $wp-color-dark-silver-gray;
transition: color $transition-base;
cursor: pointer;
background: transparent;
&:focus {
color: $wp-color-dark-silver-gray;
box-shadow: none;
}
&:hover {
color: $wp-color-dark-gray;
}
}

View file

@ -0,0 +1,21 @@
/**
* External dependencies.
*/
import { RawHTML } from '@wordpress/element';
/**
* Renders the field.
*
* @param {Object} props
* @param {Object} props.field
* @return {Object}
*/
function BlockPreviewField( { field } ) {
return (
<RawHTML className="cf-html__content cf-html__content--block-preview">
{ field.html }
</RawHTML>
);
}
export default BlockPreviewField;

View file

@ -0,0 +1,58 @@
/**
* External dependencies.
*/
import { Component, Fragment } from '@wordpress/element';
/**
* Internal dependencies.
*/
import './style.scss';
class CheckboxField extends Component {
/**
* Handles the change of the input.
*
* @param {Object} e
* @return {void}
*/
handleChange = ( e ) => {
const { id, onChange } = this.props;
onChange( id, e.target.checked );
}
/**
* Renders the component.
*
* @return {Object}
*/
render() {
const {
id,
name,
value,
field
} = this.props;
return (
<Fragment>
<input
type="checkbox"
id={ id }
name={ name }
checked={ value }
value={ value ? field.option_value : '' }
className="cf-checkbox__input"
onChange={ this.handleChange }
{ ...field.attributes }
/>
<label className="cf-checkbox__label" htmlFor={ id }>
{ field.option_label }
</label>
</Fragment>
);
}
}
export default CheckboxField;

View file

@ -0,0 +1,13 @@
/* ==========================================================================
Checkbox
========================================================================== */
.cf-checkbox__input {
.cf-field & {
margin-top: 0;
}
}
.cf-checkbox__label {
font-size: 13px;
}

View file

@ -0,0 +1,114 @@
/**
* External dependencies.
*/
import { Component } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { get } from 'lodash';
/**
* Internal dependencies.
*/
import './style.scss';
import Picker from './picker';
import { hexToRgba, rgbaToHex } from '../../utils/hex-and-rgba';
class ColorField extends Component {
/**
* Defines the initial state.
*
* @type {Object}
*/
state = {
showPicker: false
}
/**
* Returns the RGBA format of the currently set color
*
* @return {void}
*/
getBackgroundColor = () => {
const { field, value } = this.props;
const colorHex = value ? value : '#FFFFFFFF';
const [ r, g, b, a ] = hexToRgba( colorHex );
const rgbaColor = { r, g, b, a: field.alphaEnabled ? a : 1 };
return `rgba(${ Object.values( rgbaColor ).join( ', ' ) })`;
}
/**
* Handles the change of the input.
*
* @param {Object} [color]
* @return {void}
*/
handleChange = ( color ) => {
const { id, onChange, field } = this.props;
let value = get( color, 'hex', '' ).toUpperCase();
if ( field.alphaEnabled ) {
value = rgbaToHex( get( color, 'rgb', null ) );
}
onChange( id, value );
}
/**
* Toggles the visibility of the color picker component
*
* @return {void}
*/
togglePicker = () => this.setState( { showPicker: ! this.state.showPicker } )
/**
* Render a color input field.
*
* @return {React.Element}
*/
render() {
const { showPicker } = this.state;
const {
id,
name,
value,
field
} = this.props;
return (
<div className="cf-color__inner">
<input
type="hidden"
id={ id }
name={ name }
value={ value }
/>
<button type="button" className="button cf-color__toggle" onClick={ this.togglePicker }>
<span className="cf-color__preview" style={ { backgroundColor: this.getBackgroundColor() } }></span>
<span className="cf-color__toggle-text">
{ __( 'Select a color', 'carbon-fields-ui' ) }
</span>
</button>
{ showPicker && (
<Picker
color={ value }
onChange={ this.handleChange }
disableAlpha={ ! field.alphaEnabled }
presetColors={ field.palette }
onClose={ () => showPicker ? this.togglePicker() : null }
/>
) }
<button type="button" className="button-link cf-color__reset" aria-label={ __( 'Clear', 'carbon-fields-ui' ) } onClick={ () => this.handleChange() }>
<span className="dashicons dashicons-no"></span>
</button>
</div>
);
}
}
export default ColorField;

View file

@ -0,0 +1,42 @@
/**
* External dependencies.
*/
import { Component } from '@wordpress/element';
import { SketchPicker } from 'react-color';
import onClickOutside from 'react-onclickoutside';
class Picker extends Component {
/**
* Handles the click outside the main element.
*
* @return {void}
*/
handleClickOutside = () => this.props.onClose()
/**
* Render the component.
*
* @return {Object}
*/
render() {
const {
color,
onChange,
disableAlpha,
presetColors
} = this.props;
return (
<div id="carbon-color-picker-wrapper" className="cf-color__picker">
<SketchPicker
color={ color }
onChange={ onChange }
disableAlpha={ disableAlpha }
presetColors={ presetColors }
/>
</div>
);
}
}
export default onClickOutside( Picker );

View file

@ -0,0 +1,47 @@
/* ==========================================================================
Color
========================================================================== */
.cf-color__inner {
display: flex;
align-items: center;
}
.cf-color__toggle {
position: relative;
overflow: hidden;
}
.cf-color__toggle-text {
margin-left: 27px;
}
.cf-color__preview {
position: absolute;
top: 0;
left: 0;
width: 26px;
height: 100%;
border-right: 1px solid #ccc;
.cf-color__toggle:hover &,
.cf-color__toggle:active & {
border-color: #999;
}
}
.cf-color__reset {
.cf-color & {
margin-left: 5px;
text-decoration: none;
}
.cf-color &:focus {
box-shadow: none;
}
}
.cf-color__picker {
position: absolute;
z-index: 9999;
}

View file

@ -0,0 +1,182 @@
/**
* External dependencies.
*/
import cx from 'classnames';
import { Component } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies.
*/
import { getFieldType } from '../../registry/fields';
class ComplexGroup extends Component {
/**
* Handles the click on the "Toggle" button.
*
* @return {void}
*/
handleToggleClick = () => {
const {
id,
onToggle
} = this.props;
onToggle( id );
}
/**
* Handles the click on the "Clone" button.
*
* @return {void}
*/
handleCloneClick = () => {
const {
id,
onClone
} = this.props;
onClone( id );
}
/**
* Handles the click on the "Remove" button.
*
* @return {void}
*/
handleRemoveClick = () => {
const {
id,
onRemove
} = this.props;
onRemove( id );
}
/**
* Renders the component.
*
* @return {Object}
*/
render() {
const {
index,
label,
name,
prefix,
tabbed,
hidden,
dragged,
collapsed,
allowClone,
fields,
context,
onFieldSetup
} = this.props;
const groupClasses = cx(
'cf-complex__group',
{
'cf-complex__group--grid': ! tabbed,
'cf-complex__group--tabbed': tabbed,
'cf-complex__group--collapsed': collapsed,
'cf-complex__group--dragged': dragged
}
);
const toggleClasses = cx(
'dashicons-before',
'cf-complex__group-action-icon',
{
'dashicons-arrow-up': ! collapsed,
'dashicons-arrow-down': collapsed
}
);
const actionsClasses = cx(
'cf-complex__group-actions',
{
'cf-complex__group-actions--grid': ! tabbed,
'cf-complex__group-actions--tabbed': tabbed
}
);
return (
<div className={ groupClasses } hidden={ hidden }>
{ name && (
<input
type="hidden"
name={ `${ prefix }[value]` }
value={ name }
/>
) }
{ ! tabbed && (
<div className="cf-complex__group-head">
<span className="cf-complex__group-index">
{ index + 1 }
</span>
<span className="cf-complex__group-title">
{ label }
</span>
</div>
) }
{ ! dragged && (
<div className="cf-complex__group-body" hidden={ ! tabbed && collapsed }>
{ fields.map( ( field ) => {
const FieldEdit = getFieldType( field.type, context );
if ( ! FieldEdit ) {
return null;
}
const [ Field, props ] = onFieldSetup( field, {}, this.props );
return (
// The `key` will be assigned via `onFieldSetup`.
// eslint-disable-next-line react/jsx-key
<Field { ...props }>
<FieldEdit { ...props } />
</Field>
);
} ) }
</div>
) }
<div className={ actionsClasses }>
{ allowClone && (
<button type="button" title={ __( 'Duplicate', 'carbon-fields-ui' ) } className="cf-complex__group-action" onClick={ this.handleCloneClick }>
<span className="dashicons-before dashicons-admin-page cf-complex__group-action-icon"></span>
<span className="cf-complex__group-action-text">
{ __( 'Duplicate', 'carbon-fields-ui' ) }
</span>
</button>
) }
<button type="button" title={ __( 'Remove', 'carbon-fields-ui' ) } className="cf-complex__group-action" onClick={ this.handleRemoveClick }>
<span className="dashicons-before dashicons-trash cf-complex__group-action-icon"></span>
<span className="cf-complex__group-action-text">
{ __( 'Remove', 'carbon-fields-ui' ) }
</span>
</button>
{ ! tabbed && (
<button type="button" title={ __( 'Collapse', 'carbon-fields-ui' ) } className="cf-complex__group-action" onClick={ this.handleToggleClick }>
<span className={ toggleClasses }></span>
<span className="cf-complex__group-action-text">
{ __( 'Collapse', 'carbon-fields-ui' ) }
</span>
</button>
) }
</div>
</div>
);
}
}
export default ComplexGroup;

View file

@ -0,0 +1,444 @@
/**
* External dependencies.
*/
import { addFilter } from '@wordpress/hooks';
import {
Component,
Fragment,
createRef
} from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import {
get,
find,
isEmpty,
isString,
template
} from 'lodash';
/**
* The internal dependencies.
*/
import './style.scss';
import Sortable from '../../components/sortable';
import ComplexTabs from './tabs';
import ComplexInserter from './inserter';
import ComplexGroup from './group';
import ComplexPlaceholder from './placeholder';
class ComplexField extends Component {
/**
* Keeps reference to the DOM that contains the groups.
*
* @type {Object}
*/
groupsList = createRef();
/**
* Keeps reference to the DOM that contains the tabs.
*
* @type {Object}
*/
tabsList = createRef();
/**
* Local state.
*
* @type {Object}
*/
state = {
currentDraggedGroup: null,
currentTab: get( this.props.value, `0.${ this.props.groupIdKey }`, null )
};
/**
* Returns true if the field is using tabs for the layout.
*
* @return {boolean}
*/
get isTabbed() {
return this.props.field.layout.indexOf( 'tabbed' ) > -1;
}
/**
* Returns true if the maximum number of entries is reached.
*
* @return {boolean}
*/
get isMaximumReached() {
const { field, value } = this.props;
return field.max > 0 && value.length >= field.max;
}
/**
* Returns the text used in "Add Entry" button.
*
* @return {string}
*/
get inserterButtonText() {
const { field } = this.props;
return sprintf( __( 'Add %s', 'carbon-fields-ui' ), field.labels.singular_name );
}
/**
* Finds a group.
*
* @param {string} groupId
* @return {?Object}
*/
findGroup( groupId ) {
const { value, groupIdKey } = this.props;
return find( value, [ groupIdKey, groupId ] );
}
/**
* Returns a list of groups that can be added if the field
* doesn't allow duplicating of groups.
*
* @param {string} key
* @return {Object[]}
*/
getAvailableGroups( key ) {
const { field, value } = this.props;
if ( field.duplicate_groups_allowed ) {
return field.groups;
}
const existingGroupNames = value.map( ( group ) => group[ key ] );
return field.groups.filter( ( { name } ) => existingGroupNames.indexOf( name ) === -1 );
}
/**
* Returns a list of labels of existing groups.
*
* @return {string[]}
*/
getGroupLabels() {
const { field, groupValues } = this.props;
return groupValues.map( ( [ name, values ], index ) => {
const group = find( field.groups, [ 'name', name ] );
if ( ! group ) {
return 'N/A';
}
if ( ! isString( group.label_template ) ) {
return group.label;
}
try {
const label = template( group.label_template )( {
$_index: index,
...values
} );
return label || group.label;
} catch ( e ) {
// eslint-disable-next-line no-console
console.error(
sprintf(
__( 'Couldn\'t create the label of group - %s', 'carbon-fields-ui' ),
e.message
)
);
return 'N/A';
}
} );
}
/**
* Handles adding of group.
*
* @param {Object} selection
* @return {void}
*/
handleAddGroup = ( selection ) => {
const { groupIdKey, onAddGroup } = this.props;
onAddGroup( selection, ( group ) => {
if ( this.isTabbed ) {
this.handleTabsChange( group[ groupIdKey ] );
}
} );
}
/**
* Handles cloning of group.
*
* @param {string} groupId
* @return {void}
*/
handleCloneGroup = ( groupId ) => {
const { groupIdKey, onCloneGroup } = this.props;
const group = this.findGroup( groupId );
onCloneGroup( group, ( clonedGroup ) => {
if ( this.isTabbed ) {
this.handleTabsChange( clonedGroup[ groupIdKey ] );
}
} );
}
/**
* Handles removing of group.
*
* @param {string} groupId
* @return {void}
*/
handleRemoveGroup = ( groupId ) => {
const {
value,
groupIdKey,
onRemoveGroup
} = this.props;
const group = this.findGroup( groupId );
if ( this.isTabbed ) {
const currentIndex = value.indexOf( group );
const nextIndex = currentIndex > 0 ? currentIndex - 1 : 1;
this.setState( {
currentTab: get( value, `${ nextIndex }.${ groupIdKey }`, null )
} );
}
onRemoveGroup( group );
}
/**
* Handles click on the "Expand/Collapse All" button.
*
* @return {void}
*/
handleToggleAllClick = () => {
const { allGroupsAreCollapsed, onToggleAllGroups } = this.props;
onToggleAllGroups( ! allGroupsAreCollapsed );
}
/**
* Handles the start of groups sorting.
*
* @param {Object} e
* @param {Object} ui
* @return {void}
*/
handleGroupsSortStart = ( e, ui ) => {
const { value, groupIdKey } = this.props;
const index = ui.item.index();
const id = get( value, `${ index }.${ groupIdKey }`, null );
this.setState( {
currentDraggedGroup: id
} );
}
/**
* Handles sorting of groups.
*
* @param {Object[]} groups
* @return {void}
*/
handleGroupsSortUpdate = ( groups ) => {
const { id, onChange } = this.props;
onChange( id, groups );
}
/**
* Handles the stop of groups sorting
*
* @return {void}
*/
handleGroupsSortStop = () => {
this.setState( {
currentDraggedGroup: null
} );
}
/**
* Handles changing of tabs.
*
* @param {string} groupId
* @return {void}
*/
handleTabsChange = ( groupId ) => {
this.setState( {
currentTab: groupId
} );
}
/**
* Renders the component.
*
* @return {Object}
*/
render() {
const { currentDraggedGroup, currentTab } = this.state;
const {
value,
field,
groupIdKey,
groupFilterKey,
allGroupsAreCollapsed,
onGroupSetup,
onGroupFieldSetup,
onToggleGroup
} = this.props;
const availableGroups = this.getAvailableGroups( groupFilterKey );
const groupLabels = this.getGroupLabels();
// TODO: Move this to a memoized function.
const tabs = value.map( ( group, index ) => {
const id = group[ groupIdKey ];
const label = groupLabels[ index ];
return {
id,
label
};
} );
return (
<Fragment>
{ this.isTabbed && !! value.length && (
<Sortable
items={ value }
forwardedRef={ this.tabsList }
options={ {
axis: field.layout === 'tabbed-vertical' ? 'y' : 'x',
forcePlaceholderSize: true
} }
onUpdate={ this.handleGroupsSortUpdate }
>
<ComplexTabs
ref={ this.tabsList }
items={ tabs }
current={ currentTab }
layout={ field.layout }
onChange={ this.handleTabsChange }
>
{ !! availableGroups.length && ! this.isMaximumReached && (
<ComplexInserter
buttonText="+"
groups={ availableGroups }
onSelect={ this.handleAddGroup }
/>
) }
</ComplexTabs>
</Sortable>
) }
{ ! value.length && (
<ComplexPlaceholder label={ __( 'There are no entries yet.', 'carbon-fields-ui' ) }>
<ComplexInserter
buttonText={ this.inserterButtonText }
groups={ availableGroups }
onSelect={ this.handleAddGroup }
/>
</ComplexPlaceholder>
) }
{ !! value.length && (
<Sortable
items={ value }
options={ {
// axis: 'y',
helper: 'clone',
handle: '.cf-complex__group-head',
placeholder: 'cf-complex__group-placeholder',
forceHelperSize: true,
forcePlaceholderSize: true
} }
forwardedRef={ this.groupsList }
onStart={ this.handleGroupsSortStart }
onUpdate={ this.handleGroupsSortUpdate }
onStop={ this.handleGroupsSortStop }
>
<div className="cf-complex__groups" ref={ this.groupsList }>
{ value.map( ( group, index ) => (
// The `key` will be assigned via `onGroupSetup`.
// eslint-disable-next-line react/jsx-key
<ComplexGroup key={ `${ group[ groupFilterKey ] }-${ index }` } { ...onGroupSetup( group, {
index,
label: groupLabels[ index ],
dragged: group[ groupIdKey ] === currentDraggedGroup,
tabbed: this.isTabbed,
hidden: this.isTabbed && group[ groupIdKey ] !== currentTab,
allowClone: field.duplicate_groups_allowed && ! this.isMaximumReached,
onFieldSetup: onGroupFieldSetup,
onClone: this.handleCloneGroup,
onRemove: this.handleRemoveGroup,
onToggle: onToggleGroup
} ) } />
) ) }
</div>
</Sortable>
) }
{ ! this.isTabbed && !! value.length && (
<div className="cf-complex__actions">
{ !! availableGroups.length && ! this.isMaximumReached && (
<ComplexInserter
buttonText={ this.inserterButtonText }
groups={ availableGroups }
onSelect={ this.handleAddGroup }
/>
) }
<button type="button" className="button cf-complex__toggler" onClick={ this.handleToggleAllClick }>
{ allGroupsAreCollapsed ? __( 'Expand All', 'carbon-fields-ui' ) : __( 'Collapse All', 'carbon-fields-ui' ) }
</button>
</div>
) }
</Fragment>
);
}
}
addFilter( 'carbon-fields.field-wrapper', 'carbon-fields/core', ( OriginalField ) => ( props ) => {
const { field } = props;
if ( field.type !== 'complex' ) {
return <OriginalField { ...props } />;
}
return <OriginalField className={ `cf-complex--${ field.layout }` } { ...props } />;
} );
addFilter( 'carbon-fields.complex.validate', 'carbon-fields/core', ( field, value ) => {
const {
min,
labels,
required
} = field;
if ( required && isEmpty( value ) ) {
return __( 'This field is required.', 'carbon-fields-ui' );
}
if ( min > 0 && value.length < min ) {
const label = min === 1 ? labels.singular_name : labels.plural_name;
return sprintf(
__( 'Minimum number of rows not reached (%1$d %2$s)', 'carbon-fields-ui' ),
Number( min ),
label.toLowerCase()
);
}
return null;
} );
export default ComplexField;

View file

@ -0,0 +1,91 @@
/**
* External dependencies.
*/
import onClickOutside from 'react-onclickoutside';
import { Component } from '@wordpress/element';
class ComplexInserter extends Component {
/**
* Local state.
*
* @type {Object}
*/
state = {
menuVisible: false
};
/**
* Handles the click outside the main element.
*
* @return {void}
*/
handleClickOutside = () => {
this.setState( {
menuVisible: false
} );
}
/**
* Handles the click on the "Add" button.
*
* @return {void}
*/
handleAddClick = () => {
const { groups, onSelect } = this.props;
if ( groups.length > 1 ) {
this.setState( ( { menuVisible } ) => ( {
menuVisible: ! menuVisible
} ) );
} else {
onSelect( groups[ 0 ] );
}
}
/**
* Handles the click on an item in the menu.
*
* @param {Object} group
* @return {void}
*/
handleItemClick = ( group ) => {
this.setState( {
menuVisible: false
} );
this.props.onSelect( group );
}
/**
* Renders the component.
*
* @return {Object}
*/
render() {
const { buttonText, groups } = this.props;
return (
<div className="cf-complex__inserter">
<button type="button" className="button cf-complex__inserter-button" onClick={ this.handleAddClick }>
{ buttonText }
</button>
{ groups.length > 1 && (
<ul className="cf-complex__inserter-menu" hidden={ ! this.state.menuVisible }>
{ groups.map( ( group, index ) => (
<li
className="cf-complex__inserter-item"
key={ index }
onClick={ () => this.handleItemClick( group ) }
>
{ group.label }
</li>
) ) }
</ul>
) }
</div>
);
}
}
export default onClickOutside( ComplexInserter );

View file

@ -0,0 +1,21 @@
/**
* Renders the empty state of the field.
*
* @param {Object} props
* @param {string} props.label
* @param {Object} props.children
* @return {Object}
*/
function ComplexPlaceholder( { label, children } ) {
return (
<div className="cf-complex__placeholder">
<p className="cf-complex__placeholder-label">
{ label }
</p>
{ children }
</div>
);
}
export default ComplexPlaceholder;

View file

@ -0,0 +1,321 @@
/* ==========================================================================
Complex
========================================================================== */
.cf-complex__groups {
flex: 1;
position: relative;
.cf-complex--tabbed-vertical > & {
flex: 0 0 80%;
}
}
.cf-complex__group {
box-sizing: border-box;
.cf-complex--grid & {
position: relative;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
}
.cf-complex__group-placeholder {
position: relative;
&:not(:last-child) {
margin-bottom: 12px;
}
&::before {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 1px dashed $wp-color-gray-light-500;
box-sizing: border-box;
content: '';
}
}
/**
* Head
*/
.cf-complex__group-head {
position: relative;
display: flex;
border: 1px solid $wp-color-gray-light-500;
border-bottom: 0;
background-color: $wp-color-gray-light-100;
font-family: $wp-font;
font-size: $wp-font-size;
font-weight: 600;
line-height: $wp-line-height;
color: $wp-color-dark-gray;
cursor: move;
transition: border-color $transition-base;
&:hover {
border-color: $wp-color-dark-silver-gray;
}
}
.cf-complex__group-index {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-right: 1px solid $wp-color-gray-light-500;
}
.cf-complex__group-title {
display: flex;
align-items: center;
padding: 0 12px;
}
/**
* Body
*/
.cf-complex__group-body {
display: flex;
flex-wrap: wrap;
border-width: 1px 0 1px 1px;
border-style: solid;
border-color: $wp-color-gray-light-500;
background-color: $color-white;
&[hidden] {
display: none;
}
}
/**
* Actions
*/
.cf-complex__actions {
display: flex;
align-items: center;
margin-top: 12px;
}
.cf-complex__toggler {
.cf-complex__actions & {
margin-left: auto;
}
}
/**
* Inserter
*/
.cf-complex__inserter {
position: relative;
display: inline-block;
.cf-complex__tabs & {
height: 36px;
}
.cf-complex__tabs--tabbed-horizontal & {
width: 36px;
align-self: flex-end;
margin-bottom: 4px;
}
.cf-complex__tabs--tabbed-vertical & {
display: block;
}
}
.cf-complex__inserter-button {
.cf-complex__tabs & {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border: 1px solid $wp-color-gray-light-500;
border-radius: 0;
margin: 0;
background-color: $wp-color-gray-light-100;
box-shadow: none;
font-size: 18px;
line-height: 1;
transition: background-color $transition-base;
&:focus,
&:hover,
&:active {
border-color: $wp-color-gray-light-500;
background-color: $color-white;
}
&:focus {
box-shadow: none;
}
&:active {
box-shadow: none;
transform: none;
}
}
}
.cf-complex__inserter-menu {
position: absolute;
top: 50%;
left: 100%;
min-width: 180px;
margin: 0;
transform: translate(10px, -50%);
z-index: 1;
}
.cf-complex__inserter-item {
padding: 8px 12px;
margin: 0;
cursor: pointer;
transition: color $transition-base;
}
/**
* Group - Actions
*/
.cf-complex__group-actions {
display: flex;
align-items: center;
&--grid {
position: absolute;
top: 12px;
right: 12px;
}
&--tabbed {
justify-content: flex-end;
padding: 8px 12px;
border-width: 0 1px 1px;
border-style: solid;
border-color: $wp-color-gray-light-500;
background-color: $wp-color-gray-light-100;
}
}
.cf-complex__group-action {
display: inline-flex;
padding: 0;
border: 0;
margin-left: 12px;
outline: none;
background-color: transparent;
color: $wp-color-dark-silver-gray;
cursor: pointer;
transition: color $transition-base;
&:first-child {
margin-left: 0;
}
&:hover {
color: $wp-color-dark-gray;
}
}
.cf-complex__group-action-text {
display: none;
}
/**
* Tabs
*/
.cf-complex__tabs {
position: relative;
z-index: 1;
&--tabbed-horizontal {
display: inline-flex;
margin-bottom: -5px;
}
&--tabbed-vertical {
flex: 0 0 20%;
margin-right: -1px;
}
}
.cf-complex__tabs-list {
margin: 0;
.cf-complex__tabs--tabbed-horizontal & {
display: flex;
flex-wrap: wrap;
}
.cf-complex__tabs--tabbed-vertical & {
margin-bottom: -1px;
}
}
.cf-complex__tabs-item {
display: flex;
align-items: center;
padding: 8px 12px;
border: 1px solid $wp-color-gray-light-500;
margin: 0;
background-color: $wp-color-gray-light-100;
font-size: 12px;
cursor: pointer;
transition: background-color $transition-base, border-color $transition-base;
&:hover {
background-color: $color-white;
}
&--tabbed-horizontal {
margin: 0 4px 4px 0;
}
&--tabbed-vertical ~ &--tabbed-vertical {
border-top-width: 0;
}
&--current {
background-color: $color-white;
}
&--tabbed-horizontal#{&}--current {
border-bottom-color: $color-white !important;
}
&--tabbed-vertical#{&}--current {
border-right-color: $color-white !important;
}
}
/**
* Placeholder
*/
.cf-complex__placeholder-label {
&#{&} {
margin: $size-base * 2 0 $size-base * 3;
}
.cf-container-term-meta & {
font-style: normal;
color: inherit;
}
}

View file

@ -0,0 +1,68 @@
/**
* External dependencies.
*/
import cx from 'classnames';
import { forwardRef } from '@wordpress/element';
/**
* Renders the tabs navigation.
*
* @param {Object} props
* @param {Object[]} props.items
* @param {string} props.current
* @param {string} props.layout
* @param {mixed} props.children
* @param {Function} props.onChange
* @param {Object} ref
* @return {void}
*/
function ComplexTabs( {
items,
current,
layout,
children,
onChange
}, ref ) {
return (
<div className={ `cf-complex__tabs cf-complex__tabs--${ layout }` }>
<ul className="cf-complex__tabs-list" ref={ ref }>
{ items.map( ( item, index ) => {
const classes = cx(
'cf-complex__tabs-item',
`cf-complex__tabs-item--${ layout }`,
{
'cf-complex__tabs-item--current': item.id === current
}
);
return (
<li
key={ item.id }
className={ classes }
onClick={ () => onChange( item.id ) }
>
{
item.label
? (
<span
className="cf-complex__tabs-title"
dangerouslySetInnerHTML={ { __html: item.label } }
></span>
)
: (
<span className="cf-complex__tabs-index">
{ index + 1 }
</span>
)
}
</li>
);
} ) }
</ul>
{ children }
</div>
);
}
export default forwardRef( ComplexTabs );

View file

@ -0,0 +1,17 @@
/**
* External dependencies.
*/
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies.
*/
import withProps from '../../hocs/with-props';
import DatetimeField from '../datetime';
export default withProps( ( props ) => {
return {
...props,
buttonText: __( 'Select Date', 'carbon-fields-ui' )
};
} )( DatetimeField );

View file

@ -0,0 +1,134 @@
/**
* External dependencies.
*/
import { Component } from '@wordpress/element';
import Flatpickr from 'react-flatpickr';
/**
* Internal dependencies.
*/
import './style.scss';
class DateTimeField extends Component {
/**
* Keeps reference to the instance of Flatpickr.
*
* @type {Object}
*/
picker = null;
/**
* Lifecycle hook.
*
* @return {void}
*/
componentWillUnmount() {
this.picker = null;
}
/**
* Handles the intialization of the flatpickr component.
*
* @param {Date[]} selectedDates
* @param {string} selectedDateStr
* @param {Object} instance
* @return {void}
*/
handleReady = ( selectedDates, selectedDateStr, instance ) => {
this.picker = instance;
}
/**
* Handles the change.
*
* @param {Date[]} selectedDates
* @param {string} selectedDateStr
* @return {void}
*/
handleChange = ( selectedDates, selectedDateStr ) => {
const {
id,
onChange,
value
} = this.props;
if ( selectedDateStr !== value ) {
onChange( id, selectedDateStr );
}
}
/**
* Handles manual input of dates.
*
* @param {Object} e
* @return {void}
*/
handleManualInput = ( e ) => {
const {
id,
onChange,
value
} = this.props;
if ( e.target.value !== value ) {
onChange( id, e.target.value );
}
}
/**
* Formats the date added manually.
*
* @param {Object} e
* @return {void}
*/
formatManualInput = ( e ) => {
this.picker.setDate( e.target.value, true );
}
/**
* Render the component.
*
* @return {Object}
*/
render() {
const {
id,
name,
value,
field,
icon,
buttonText
} = this.props;
return (
<Flatpickr
options={ {
...field.picker_options,
wrap: true
} }
value={ value }
onReady={ this.handleReady }
onChange={ this.handleChange }
className={ `cf-datetime__inner dashicons-before dashicons-${ icon || 'calendar' }` }
>
<input
type="text"
id={ id }
name={ name }
value={ value }
onChange={ this.handleManualInput }
onBlur={ this.formatManualInput }
className="cf-datetime__input"
data-input
{ ...field.attributes }
/>
<button type="button" className="button cf-datetime__button" data-toggle>
{ buttonText }
</button>
</Flatpickr>
);
}
}
export default DateTimeField;

View file

@ -0,0 +1,34 @@
/* ==========================================================================
DateTime
========================================================================== */
@import '~flatpickr/dist/flatpickr.min.css';
.cf-datetime__inner {
display: flex;
align-items: stretch;
flex-wrap: wrap;
margin-top: -5px;
&::before {
display: none;
}
}
.cf-datetime__input {
flex: 1;
margin: 5px 6px 0 0;
}
.wp-core-ui .button.cf-datetime__button {
margin-top: 5px;
}
.cf-datetime__button {
flex: 0 0 auto;
.cf-field & {
margin-bottom: 0;
box-shadow: none;
}
}

View file

@ -0,0 +1,186 @@
/**
* External dependencies.
*/
import { Component } from '@wordpress/element';
import { get } from 'lodash';
/**
* Internal dependencies.
*/
import './style.scss';
import MediaLibrary from '../../components/media-library';
import apiFetch from '../../utils/api-fetch';
class FileField extends Component {
/**
* Local state.
*
* @type {Object}
*/
state = {
data: {}
};
/**
* Lifecycle Hook.
*
* @return {void}
*/
componentDidMount() {
const { value, field } = this.props;
if ( value ) {
let endpoint = '';
if ( window.wpApiSettings.root.indexOf( '?rest_route' ) !== -1 ) {
endpoint = `${ window.wpApiSettings.root }carbon-fields/v1/attachment&type=${ field.value_type }&value=${ value }`;
} else {
endpoint = `${ window.wpApiSettings.root }carbon-fields/v1/attachment?type=${ field.value_type }&value=${ value }`;
}
// TODO: Refactor this to use `@wordpress/api-fetch` package.
apiFetch(
endpoint,
'get'
).then( this.handleFileDataChange );
}
}
/**
* Returns an URL to the attachment's thumbnail.
*
* @return {string}
*/
getThumb() {
const { data } = this.state;
if ( data.sizes ) {
const size = data.sizes.thumbnail || data.sizes.full;
if ( size ) {
return size.url;
}
}
if ( data.thumb_url ) {
return data.thumb_url;
}
return data.icon;
}
/**
* Returns the filename to the attachment thumbnail.
*
* @return {string}
*/
getFileName() {
const { data } = this.state;
return data.filename || data.file_name;
}
/**
* Handles the file meta set.
*
* @param {Object} data
* @return {void}
*/
handleFileDataChange = ( data ) => {
this.setState( { data } );
}
/**
* Handles the clear action of the file field.
*
* @return {void}
*/
handleClear = () => {
const { id, onChange } = this.props;
onChange( id, '' );
this.handleFileDataChange( {} );
}
/**
* Handles the file selection.
*
* @param {Object} files
* @return {void}
*/
handleSelect = ( files ) => {
const {
id,
field,
onChange
} = this.props;
const [ file ] = files;
onChange( id, get( file, field.value_type, file.id ) );
this.handleFileDataChange( file );
}
/**
* Render the component.
*
* @return {Object}
*/
render() {
const { data } = this.state;
const {
value,
name,
field,
buttonLabel,
mediaLibraryButtonLabel,
mediaLibraryTitle
} = this.props;
return (
<MediaLibrary
onSelect={ this.handleSelect }
multiple={ false }
title={ mediaLibraryTitle }
buttonLabel={ mediaLibraryButtonLabel }
typeFilter={ field.type_filter }
>
{
( { openMediaBrowser } ) => {
return <div className="cf-file__inner">
<input
type="hidden"
name={ name }
value={ value }
readOnly
/>
{ ( value && !! data.id ) && (
<div className="cf-file__content">
<div className="cf-file__preview">
<img src={ this.getThumb() } className="cf-file__image" />
<button type="button" className="cf-file__remove dashicons-before dashicons-no-alt" onClick={ this.handleClear }></button>
</div>
<span className="cf-file__name" title={ this.getFileName() }>
{ this.getFileName() }
</span>
</div>
) }
<button type="button" className="button cf-file__browse" onClick={ openMediaBrowser }>
{ buttonLabel }
</button>
</div>;
}
}
</MediaLibrary>
);
}
}
export default FileField;

View file

@ -0,0 +1,94 @@
/* ==========================================================================
File
========================================================================== */
.cf-file__inner {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 130px;
height: 130px;
border: 1px dashed $wp-color-gray-light-800;
box-sizing: border-box;
}
.cf-file__content {
position: absolute;
top: -1px;
right: -1px;
bottom: -1px;
left: -1px;
border: 1px solid $wp-color-gray-light-800;
}
.cf-file__preview {
position: absolute;
top: 0;
left: 0;
bottom: 28px;
width: 100%;
overflow: hidden;
background-color: $wp-color-gray-light-500;
box-shadow: 0 0 15px transparentize($color-black, .9) inset;
}
.cf-file__image {
position: absolute;
top: 50%;
left: 50%;
height: auto;
max-width: 100%;
transform: translate(-50%, -50%);
}
.cf-file__name {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 5px;
border-top: 1px solid $wp-color-gray-light-800;
overflow: hidden;
background-color: $wp-color-gray-light-200;
white-space: nowrap;
text-overflow: ellipsis;
}
.cf-file__browse {
position: relative;
.cf-file__content ~ & {
margin-bottom: 29px;
visibility: hidden;
opacity: 0;
transition: visibility $transition-base, opacity $transition-base;
}
.cf-file__inner:hover .cf-file__content ~ & {
visibility: visible;
opacity: 1;
}
}
.cf-file__remove {
position: absolute;
top: 5px;
right: 5px;
padding: 0;
border: 0;
outline: none;
background-color: transparent;
cursor: pointer;
transition: opacity $transition-base;
&:hover {
opacity: .8;
}
&::before {
border-radius: 50%;
background-color: $wp-color-ultra-dark-gray;
color: $color-white;
}
}

View file

@ -0,0 +1,31 @@
/**
* External dependencies.
*/
import { Component } from '@wordpress/element';
class HiddenField extends Component {
/**
* Renders the component.
*
* @return {Object}
*/
render() {
const {
name,
value,
field
} = this.props;
return (
<input
type="hidden"
name={ name }
value={ value }
className="hidden-text"
{ ...field.attributes }
/>
);
}
}
export default HiddenField;

View file

@ -0,0 +1,21 @@
/**
* External dependencies.
*/
import { RawHTML } from '@wordpress/element';
/**
* Renders the field.
*
* @param {Object} props
* @param {Object} props.field
* @return {Object}
*/
function HtmlField( { field } ) {
return (
<RawHTML className="cf-html__content">
{ field.html }
</RawHTML>
);
}
export default HtmlField;

View file

@ -0,0 +1,79 @@
/**
* External dependencies.
*/
import { compose } from '@wordpress/compose';
import { addFilter } from '@wordpress/hooks';
/**
* Internal dependencies.
*/
import withFilters from '../hocs/with-filters';
import { registerFieldType } from '../registry/fields';
import AssociationField from './association';
import CheckboxField from './checkbox';
import ColorField from './color';
import ComplexField from './complex';
import DateField from './date';
import DateTimeField from './datetime';
import FileField from './file';
import HiddenField from './hidden';
import HtmlField from './html';
import MapField from './map';
import MediaGalleryField from './media-gallery';
import MultiselectField from './multiselect';
import OembedField from './oembed';
import RadioField from './radio';
import RadioImageField from './radio-image';
import RichTextField from './rich-text';
import SelectField from './select';
import SeparatorField from './separator';
import SetField from './set';
import SidebarField from './sidebar';
import TextField from './text';
import TextareaField from './textarea';
import TimeField from './time';
import BlockPreviewField from './block-preview';
/**
* Extends the fields with necessary hooks.
*/
addFilter( 'carbon-fields.register-field-type', 'carbon-fields/core', ( type, context, component ) => {
return compose(
withFilters( `carbon-fields.field-edit.${ context }` ),
withFilters( `carbon-fields.${ type }.${ context }` )
)( component );
} );
/**
* Registers the fields.
*/
[
[ 'association', AssociationField ],
[ 'checkbox', CheckboxField ],
[ 'color', ColorField ],
[ 'complex', ComplexField ],
[ 'date', DateField ],
[ 'date_time', DateTimeField ],
[ 'file', FileField ],
[ 'footer_scripts', TextareaField ],
[ 'gravity_form', SelectField ],
[ 'header_scripts', TextareaField ],
[ 'hidden', HiddenField ],
[ 'html', HtmlField ],
[ 'image', FileField ],
[ 'map', MapField ],
[ 'multiselect', MultiselectField ],
[ 'media_gallery', MediaGalleryField ],
[ 'oembed', OembedField ],
[ 'radio', RadioField ],
[ 'radio_image', RadioImageField ],
[ 'rich_text', RichTextField ],
[ 'select', SelectField ],
[ 'separator', SeparatorField ],
[ 'set', SetField ],
[ 'sidebar', SidebarField ],
[ 'text', TextField ],
[ 'textarea', TextareaField ],
[ 'time', TimeField ],
[ 'block_preview', BlockPreviewField ]
].forEach( ( field ) => registerFieldType( ...field ) );

View file

@ -0,0 +1,164 @@
/**
* External dependencies.
*/
import { Component, createRef } from '@wordpress/element';
class GoogleMap extends Component {
/**
* Keeps references to the DOM node.
*
* @type {Object}
*/
node = createRef();
/**
* Lifecycle hook.
*
* @return {void}
*/
componentDidMount() {
this.setupMap();
this.setupMapEvents();
this.updateMap( this.props );
const resizeObserver = new ResizeObserver( () => {
this.updateMap( this.props );
} );
resizeObserver.observe( this.node.current );
this.observer = resizeObserver;
}
/**
* Lifecycle hook.
*
* @return {void}
*/
componentDidUpdate() {
const {
lat,
lng,
zoom
} = this.props;
if ( this.marker ) {
const markerLat = this.marker.getPosition().lat();
const markerLng = this.marker.getPosition().lng();
const mapZoom = this.map.getZoom();
if ( lat !== markerLat || lng !== markerLng ) {
const location = new window.google.maps.LatLng( lat, lng );
this.marker.setPosition( location );
this.map.setCenter( location );
}
if ( zoom !== mapZoom ) {
this.map.setZoom( zoom );
}
}
this.updateMap( this.props );
}
/**
* Lifecycle hook.
*
* @return {void}
*/
componentWillUnmount() {
this.observer.disconnect();
window.google.maps.event.clearInstanceListeners( this.map );
}
/**
* Initializes the map into placeholder element.
*
* @return {void}
*/
setupMap() {
const {
lat,
lng,
zoom
} = this.props;
const position = new window.google.maps.LatLng( lat, lng );
this.map = new window.google.maps.Map( this.node.current, {
zoom,
center: position,
mapTypeId: window.google.maps.MapTypeId.ROADMAP,
scrollwheel: false
} );
this.marker = new window.google.maps.Marker( {
position,
map: this.map,
draggable: true
} );
}
/**
* Adds the listeners for the map's events.
*
* @return {void}
*/
setupMapEvents() {
const enableScrollZoom = () => {
this.map.setOptions( {
scrollwheel: true
} );
};
window.google.maps.event.addListenerOnce( this.map, 'click', enableScrollZoom );
window.google.maps.event.addListenerOnce( this.map, 'dragend', enableScrollZoom );
// Update the zoom when the map is changed.
window.google.maps.event.addListener( this.map, 'zoom_changed', () => {
this.props.onChange( {
zoom: this.map.getZoom()
} );
} );
// Update the position when the marker is moved.
window.google.maps.event.addListener( this.marker, 'dragend', () => {
this.props.onChange( {
lat: this.marker.getPosition().lat(),
lng: this.marker.getPosition().lng()
} );
} );
}
/**
* Re-draws the map.
*
* @param {Object} props
* @return {void}
*/
updateMap( props ) {
const { lat, lng } = props;
const location = new window.google.maps.LatLng( lat, lng );
setTimeout( () => {
window.google.maps.event.trigger( this.map, 'resize' );
this.map.setCenter( location );
}, 10 );
}
/**
* Renders the component.
*
* @return {Object}
*/
render() {
return (
<div ref={ this.node } className={ this.props.className }></div>
);
}
}
export default GoogleMap;

View file

@ -0,0 +1,196 @@
/**
* External dependencies.
*/
import of from 'callbag-of';
import { Component, Fragment } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { withEffects, toProps } from 'refract-callbag';
import { debounce } from 'lodash';
import {
map,
pipe,
merge
} from 'callbag-basics';
/**
* The internal dependencies.
*/
import './style.scss';
import SearchInput from '../../components/search-input';
import GoogleMap from './google-map';
class MapField extends Component {
/**
* Handles the change of search.
*
* @param {string} address
* @return {void}
*/
handleSearchChange = debounce( ( address ) => {
if ( address ) {
this.props.onGeocodeAddress( { address } );
}
}, 250 )
/**
* Handles the change of map location.
*
* @param {Object} location
* @return {void}
*/
handleMapChange = ( location ) => {
const {
id,
value,
onChange
} = this.props;
onChange( id, {
...value,
...location
} );
}
/**
* Renders the component.
*
* @return {Object}
*/
render() {
const {
id,
name,
value
} = this.props;
return (
<Fragment>
<SearchInput
id={ id }
className="cf-map__search"
name={ `${ name }[address]` }
value={ value.address }
onChange={ this.handleSearchChange }
/>
<GoogleMap
className="cf-map__canvas"
lat={ value.lat }
lng={ value.lng }
zoom={ value.zoom }
onChange={ this.handleMapChange }
/>
<input
type="hidden"
name={ `${ name }[lat]` }
value={ value.lat }
/>
<input
type="hidden"
name={ `${ name }[lng]` }
value={ value.lng }
readOnly
/>
<input
type="hidden"
name={ `${ name }[zoom]` }
value={ value.zoom }
readOnly
/>
</Fragment>
);
}
}
/**
* The function that controls the stream of side-effects.
*
* @param {Object} component
* @return {Object}
*/
function aperture( component ) {
const [ geocodeAddress$, geocodeAddress ] = component.useEvent( 'geocodeAddress' );
const geocodeAddressProps$ = pipe(
of( {
onGeocodeAddress: geocodeAddress
} ),
map( toProps )
);
const geocodeAddressEffect$ = pipe(
geocodeAddress$,
map( ( payload ) => ( {
type: 'GEOCODE_ADDRESS',
payload: payload
} ) )
);
return merge( geocodeAddressProps$, geocodeAddressEffect$ );
}
/**
* The function that causes the side effects.
*
* @param {Object} props
* @return {Function}
*/
function handler( props ) {
return function( effect ) {
const { payload, type } = effect;
const {
id,
value,
onChange
} = props;
switch ( type ) {
case 'GEOCODE_ADDRESS':
const geocode = ( address ) => {
return new Promise( ( resolve, reject ) => {
const geocoder = new window.google.maps.Geocoder();
geocoder.geocode( { address }, ( results, status ) => {
if ( status === window.google.maps.GeocoderStatus.OK ) {
const { location } = results[ 0 ].geometry;
resolve( {
lat: location.lat(),
lng: location.lng()
} );
} else if ( status === 'ZERO_RESULTS' ) {
reject( __( 'The address could not be found.', 'carbon-fields-ui' ) );
} else {
reject( `${ __( 'Geocode was not successful for the following reason: ', 'carbon-fields-ui' ) } ${ status }` );
}
} );
} );
};
geocode( payload.address )
.then( ( { lat, lng } ) => {
onChange( id, {
...value,
address: payload.address,
value: `${ lat },${ lng }`,
lat,
lng
} );
} )
.catch( ( alert ) => {
// eslint-disable-next-line
console.log( __( 'Error alert', 'carbon-fields-ui' ) );
// eslint-disable-next-line
console.log( alert );
} );
break;
}
};
}
export default withEffects( aperture, { handler } )( MapField );

View file

@ -0,0 +1,18 @@
/* ==========================================================================
Map
========================================================================== */
.cf-map__search {
position: relative;
z-index: 1;
}
.cf-map__canvas {
position: relative;
z-index: 0;
height: 300px;
border-width: 0 1px 1px;
border-style: solid;
border-color: $wp-color-gray-light-500;
background-color: $wp-color-gray-light-200;
}

View file

@ -0,0 +1,258 @@
/**
* External dependencies.
*/
import produce from 'immer';
import { withEffects } from 'refract-callbag';
import { map, pipe } from 'callbag-basics';
import { Component, createRef } from '@wordpress/element';
import { withState, compose } from '@wordpress/compose';
/**
* Internal dependencies.
*/
import './style.scss';
import MediaLibrary from '../../components/media-library';
import Sortable from '../../components/sortable';
import fetchAttachmentsData from '../../utils/fetch-attachments-data';
class MediaGalleryField extends Component {
/**
* Keeps reference to the list that contains selected attachments.
*
* @type {Object}
*/
attachmentsList = createRef();
/**
* Handles the file selection.
*
* @param {Object} attachments
* @return {void}
*/
handleSelect = ( attachments ) => {
const {
id,
onChange,
setState,
value
} = this.props;
onChange( id, [ ...value, ...attachments.map( ( attachment ) => attachment.id ) ] );
setState( {
attachmentsData: [ ...this.props.attachmentsData, ...attachments ]
} );
}
/**
* Handles the removal of an item.
*
* @param {number} index
* @return {void}
*/
handleAttachmentRemove = ( index ) => {
const {
id,
value,
onChange
} = this.props;
onChange( id, produce( value, ( draft ) => {
draft.splice( index, 1 );
} ) );
}
/**
* Handles the media item selection.
*
* @param {number} itemId
* @return {void}
*/
handleAttachmentSelect = ( itemId ) => {
const { setState } = this.props;
setState( ( { selectedItem } ) => ( {
selectedItem: selectedItem !== itemId ? itemId : null
} ) );
}
/**
* Handles sorting of attachments.
*
* @param {Object[]} attachments
* @return {void}
*/
handleSort = ( attachments ) => {
const { id, onChange } = this.props;
onChange( id, attachments );
}
/**
* Returns an URL to the attachment's thumbnail.
*
* @param {Object} attachment
* @return {string}
*/
getAttachmentThumb( attachment ) {
if ( attachment.sizes ) {
const size = attachment.sizes.thumbnail || attachment.sizes.full;
if ( size ) {
return size.url;
}
}
return attachment.url;
}
/**
* Render the component.
*
* @return {Object}
*/
render() {
const {
name,
value,
field,
buttonLabel,
mediaLibraryButtonLabel,
mediaLibraryTitle,
attachmentsData,
selectedItem
} = this.props;
return (
<Sortable
items={ value }
forwardedRef={ this.attachmentsList }
options={ {
handle: '.cf-media-gallery__item-name',
forcePlaceholderSize: true
} }
onUpdate={ this.handleSort }
>
<MediaLibrary
onSelect={ this.handleSelect }
multiple={ true }
title={ mediaLibraryTitle }
buttonLabel={ mediaLibraryButtonLabel }
typeFilter={ field.type_filter }
>
{
( { openMediaBrowser } ) => {
return (
<div className="cf-media-gallery__inner">
<ul className="cf-media-gallery__list" ref={ this.attachmentsList }>
{ value.map( ( id, index ) => { // eslint-disable-line no-shadow
const attachment = attachmentsData.find( ( attachmentData ) => attachmentData.id === id );
const className = [ 'cf-media-gallery__item' ];
const isAttachmentLoaded = !! attachment;
if ( isAttachmentLoaded ) {
className.push( `cf-media-gallery__item--${ attachment.type }` );
}
if ( selectedItem === index ) {
className.push( 'cf-media-gallery__item--selected' );
}
return (
<li className={ className.join( ' ' ) } key={ index } onClick={ () => this.handleAttachmentSelect( index ) }>
<div className="cf-media-gallery__item-inner">
<div className="cf-media-gallery__item-preview">
{ isAttachmentLoaded && (
attachment.type === 'image'
? (
<img
className="cf-media-gallery__item-thumb"
src={ this.getAttachmentThumb( attachment ) }
/>
)
: (
<img
className="cf-media-gallery__item-icon"
src={ attachment.icon }
/>
)
) }
</div>
{ isAttachmentLoaded && (
<span className="cf-media-gallery__item-name">
{ attachment.filename }
</span>
) }
{ isAttachmentLoaded && (
<button
type="button"
className="cf-media-gallery__item-remove dashicons-before dashicons-no-alt"
onClick={ () => this.handleAttachmentRemove( index ) }
></button>
) }
</div>
<input
type="hidden"
name={ `${ name }[${ index }]` }
value={ id }
readOnly
/>
</li>
);
} ) }
</ul>
<div className="cf-media-gallery__actions">
<button type="button" className="button cf-media-gallery__browse" onClick={ openMediaBrowser }>
{ buttonLabel }
</button>
</div>
</div>
);
}
}
</MediaLibrary>
</Sortable>
);
}
}
function aperture( component ) {
const mount$ = component.mount;
return pipe( mount$, map( () => ( {
type: 'COMPONENT_MOUNTED'
} ) ) );
}
function handler( props ) {
return function( effect ) {
switch ( effect.type ) {
case 'COMPONENT_MOUNTED':
const { value, setState } = props;
fetchAttachmentsData( value ).then( ( attachmentsData ) => {
setState( {
attachmentsData: [ ...props.attachmentsData, ...attachmentsData ]
} );
} );
break;
}
};
}
const applyWithState = withState( {
attachmentsData: [],
selectedItem: null
} );
const applyWithEffects = withEffects( aperture, { handler } );
export default compose(
applyWithState,
applyWithEffects
)( MediaGalleryField );

View file

@ -0,0 +1,128 @@
/* ==========================================================================
Media Gallery
========================================================================== */
.cf-media-gallery__list {
display: flex;
flex-wrap: wrap;
max-height: 400px;
padding: 4px;
margin: 0;
overflow-y: auto;
list-style: none outside none;
&:empty {
display: none;
}
}
.cf-media-gallery__actions {
padding: 8px;
.cf-media-gallery__list:empty ~ & {
border-top-width: 0;
}
}
.cf-media-gallery__item {
flex: 0 0 100%;
min-width: 0;
padding: 4px;
margin: 0;
box-sizing: border-box;
@media (min-width: 320px) {
flex-basis: 50%;
}
@media (min-width: 480px) {
flex-basis: 33.3333%;
}
@media (min-width: 640px) {
flex-basis: 25%;
}
@media (min-width: 768px) {
flex-basis: 20%;
}
@media (min-width: 1280px) {
flex-basis: 16.66667%;
}
@media (min-width: 1440px) {
flex-basis: 12.5%;
}
@media (min-width: 1680px) {
flex-basis: 10%;
}
}
.cf-media-gallery__item-inner {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
}
.cf-media-gallery__item-preview {
position: relative;
overflow: hidden;
padding-top: 100%;
flex: 1;
}
.cf-media-gallery__item-thumb {
position: absolute;
top: 50%;
left: 50%;
min-width: 100%;
min-height: 100%;
max-width: 150%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.cf-media-gallery__item-icon {
position: absolute;
top: 50%;
left: 50%;
max-width: 100%;
max-height: 50%;
transform: translate(-50%, -50%);
}
.cf-media-gallery__item-name {
display: block;
padding: 4px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
cursor: move;
}
.cf-media-gallery__item-remove {
position: absolute;
top: 4px;
right: 4px;
padding: 0;
border: 0;
outline: none;
background-color: transparent;
cursor: pointer;
&::before {
border-radius: 50%;
background-color: $wp-color-ultra-dark-gray;
color: $color-white;
transition: color $transition-base;
}
&:hover::before {
color: $wp-color-gray-light-800;
}
}

View file

@ -0,0 +1,74 @@
/**
* External dependencies.
*/
import { Component } from '@wordpress/element';
import Select from 'react-select';
/**
* The internal dependencies.
*/
import './style.scss';
import NoOptions from '../../components/no-options';
class MultiselectField extends Component {
/**
* Handles the change of the input.
*
* @param {Object} selected
* @return {void}
*/
handleChange = ( selected ) => {
const {
id,
onChange
} = this.props;
onChange( id, selected?.map( ( item ) => item.value ) ?? [] );
}
/**
* Filter the field options which are contained as values
*
* @param {Array} values
* @return {Array}
*/
filterValues = ( values ) => {
const { field } = this.props;
return values.map( ( value ) => field.options.find( ( option ) => option.value === value ) );
}
/**
* Renders the component.
*
* @return {Object}
*/
render() {
const {
id,
name,
value,
field
} = this.props;
return (
field.options.length > 0
? (
<Select
id={ id }
name={ name }
value={ this.filterValues( value ) }
options={ field.options }
delimiter={ field.valueDelimiter }
onChange={ this.handleChange }
className="cf-multiselect__select"
classNamePrefix="cf-multiselect"
isMulti
/>
)
: <NoOptions />
);
}
}
export default MultiselectField;

View file

@ -0,0 +1,80 @@
/* ==========================================================================
Multiselect
========================================================================== */
.cf-multiselect__control {
min-height: 0;
border-color: $wp-color-gray-light-500;
&:hover {
border-color: $wp-color-gray-light-500;
}
&--is-focused,
&--is-focused:hover {
border-color: $wp-color-medium-blue !important;
box-shadow: none;
}
}
.cf-multiselect__placeholder {
color: $wp-color-gray-light-800;
}
.cf-multiselect__value-container {
padding-left: $size-base;
padding-right: $size-base;
}
.cf-multiselect__multi-value {
align-items: center;
padding: 5px 3px;
margin: 0;
background-color: $wp-color-gray-light-500;
& + & {
margin-left: 5px;
}
}
.cf-multiselect__multi-value__label {
padding-left: 3px;
font-size: 13px;
line-height: 1;
}
.cf-multiselect__multi-value__remove {
padding: 0;
margin-top: 1px;
cursor: pointer;
&:hover {
background-color: transparent;
}
}
.cf-multiselect__input {
input[id],
input[id]:focus {
box-shadow: none;
}
}
.cf-multiselect__menu {
z-index: 9999;
}
.cf-multiselect__option {
padding: $size-base;
&--is-focused {
background-color: $wp-color-medium-blue;
color: $color-white;
}
}
.cf-multiselect__indicator {
padding: 5px;
cursor: pointer;
}

View file

@ -0,0 +1,225 @@
/**
* External dependencies.
*/
import { Component, createRef } from '@wordpress/element';
import { compose, withState } from '@wordpress/compose';
import { __ } from '@wordpress/i18n';
import { withEffects, toProps } from 'refract-callbag';
import {
map,
pipe,
merge
} from 'callbag-basics';
import of from 'callbag-of';
import {
isEmpty,
debounce
} from 'lodash';
/**
* The internal dependencies.
*/
import './style.scss';
import SearchInput from '../../components/search-input';
import OembedPreview from './preview';
class OembedField extends Component {
/**
* Keeps references to the DOM node.
*
* @type {Object}
*/
node = createRef();
/**
* Lifecycle hook.
*
* @return {void}
*/
componentDidMount() {
const { value } = this.props;
const i = setInterval( () => {
if ( this.node.current !== null && this.node.current.getBoundingClientRect().width > 0 ) {
clearInterval( i );
this.handleSearch( value );
}
}, 100 );
}
/**
* Handles the load of the oembed preview.
*
* @param {String} value
* @return {void}
*/
handleSearch = debounce( ( value ) => {
const {
isLoading,
setState,
onFetchEmbedCode
} = this.props;
if ( isLoading ) {
return;
}
setState( {
embedCode: '',
error: ''
} );
if ( isEmpty( value ) ) {
return;
}
setState( {
isLoading: true
} );
onFetchEmbedCode( value );
}, 200 )
/**
* Handles the change of the input.
*
* @param {string} value
* @return {void}
*/
handleChange = ( value ) => {
const { id, onChange } = this.props;
onChange( id, value );
this.handleSearch( value );
}
/**
* Renders the component.
*
* @return {Object}
*/
render() {
const {
id,
name,
value,
embedCode,
embedType,
provider
} = this.props;
return (
<div ref={ this.node }>
<SearchInput
id={ id }
value={ value }
onChange={ this.handleChange }
/>
{
embedCode
? <OembedPreview
html={ embedCode }
type={ embedType }
provider={ provider }
/>
: null
}
<input
type="hidden"
name={ name }
value={ value }
readOnly
/>
</div>
);
}
}
/**
* The function that controls the stream of side-effects.
*
* @param {Object} component
* @return {Object}
*/
function aperture( component ) {
const [ fetchEmbedCode$, fetchEmbedCode ] = component.useEvent( 'fetchEmbedCode' );
const fetchEmbedCodeProps$ = pipe(
of( {
onFetchEmbedCode: fetchEmbedCode
} ),
map( toProps )
);
const fetchEmbedCodeEffect$ = pipe(
fetchEmbedCode$,
map( ( payload ) => ( {
type: 'FETCH_EMBED_CODE',
payload: payload
} ) )
);
return merge( fetchEmbedCodeProps$, fetchEmbedCodeEffect$ );
}
/**
* The function that causes the side effects.
*
* @param {Object} props
* @return {Function}
*/
function handler( props ) {
return function( effect ) {
const { payload, type } = effect;
switch ( type ) {
case 'FETCH_EMBED_CODE':
const request = window.jQuery.get( window.wpApiSettings.root + 'oembed/1.0/proxy', {
url: payload,
_wpnonce: window.wpApiSettings.nonce
} );
/* eslint-disable-next-line no-alert */
const errorHandler = () => alert( __( 'An error occurred while trying to fetch oembed preview.', 'carbon-fields-ui' ) );
request.done( ( response ) => {
props.setState( {
embedCode: response.html,
embedType: response.type,
provider: response.provider_name,
isLoading: false
} );
} );
request.fail( () => {
errorHandler();
props.setState( {
error: __( 'Not Found', 'carbon-fields-ui' ),
isLoading: false
} );
} );
break;
}
};
}
const applyWithState = withState( {
embedCode: '',
embedType: '',
provider: '',
error: '',
isLoading: false
} );
const applyWithEffects = withEffects( aperture, { handler } );
export default compose(
applyWithState,
applyWithEffects
)( OembedField );

View file

@ -0,0 +1,188 @@
/**
* External dependencies.
*/
import { Component, renderToString } from '@wordpress/element';
class OembedPreview extends Component {
constructor() {
super( ...arguments );
this.state = {
width: 0,
height: 0
};
this.renderIframe = this.renderIframe.bind( this );
this.checkMessageForResize = this.checkMessageForResize.bind( this );
}
isFrameAccessible() {
try {
return !! this.iframe.contentDocument.body;
} catch ( e ) {
return false;
}
}
/**
* Lifecycle hook.
*
* @return {void}
*/
componentDidMount() {
window.addEventListener( 'message', this.checkMessageForResize, false );
this.renderIframe();
}
/**
* Lifecycle Hook.
*
* @return {void}
*/
componentDidUpdate() {
this.renderIframe();
}
checkMessageForResize( event ) {
const iframe = this.iframe;
// Attempt to parse the message data as JSON if passed as string
let data = event.data || {};
if ( 'string' === typeof data ) {
try {
data = JSON.parse( data );
} catch ( e ) {} // eslint-disable-line no-empty
}
// Verify that the mounted element is the source of the message
if ( ! iframe || iframe.contentWindow !== event.source ) {
return;
}
// Update the state only if the message is formatted as we expect, i.e.
// as an object with a 'resize' action, width, and height
const { action, width, height } = data;
const { width: oldWidth, height: oldHeight } = this.state;
if ( 'resize' === action && ( oldWidth !== width || oldHeight !== height ) ) {
this.setState( { width, height } );
}
}
/**
* Render Method.
*
* @return {Object}
*/
render() {
return <div className="cf-oembed__preview">
<iframe
ref={ ( node ) => this.iframe = node }
scrolling="no"
className="cf-oembed__frame"
onLoad={ this.renderIframe }
width={ Math.ceil( this.state.width ) }
height={ Math.ceil( this.state.height ) }
/>
</div>;
}
renderIframe() {
if ( ! this.isFrameAccessible() ) {
return;
}
const body = this.iframe.contentDocument.body;
if ( null !== body.getAttribute( 'data-resizable-iframe-connected' ) ) {
return;
}
const heightCalculation = 'video' === this.props.type ? 'clientBoundingRect.width / 16 * 9' : 'clientBoundingRect.height';
const observeAndResizeJS = `
( function() {
var observer;
if ( ! window.MutationObserver || ! document.body || ! window.parent ) {
return;
}
function sendResize() {
var clientBoundingRect = document.body.getBoundingClientRect();
window.parent.postMessage( {
action: 'resize',
width: clientBoundingRect.width,
height: ${ heightCalculation }
}, '*' );
}
observer = new MutationObserver( sendResize );
observer.observe( document.body, {
attributes: true,
attributeOldValue: false,
characterData: true,
characterDataOldValue: false,
childList: true,
subtree: true
} );
window.addEventListener( 'load', sendResize, true );
// Hack: Remove viewport unit styles, as these are relative
// the iframe root and interfere with our mechanism for
// determining the unconstrained page bounds.
function removeViewportStyles( ruleOrNode ) {
[ 'width', 'height', 'minHeight', 'maxHeight' ].forEach( function( style ) {
if ( /^\\d+(vmin|vmax|vh|vw)$/.test( ruleOrNode.style[ style ] ) ) {
ruleOrNode.style[ style ] = '';
}
} );
}
Array.prototype.forEach.call( document.querySelectorAll( '[style]' ), removeViewportStyles );
Array.prototype.forEach.call( document.styleSheets, function( stylesheet ) {
Array.prototype.forEach.call( stylesheet.cssRules || stylesheet.rules, removeViewportStyles );
} );
document.body.setAttribute( 'data-resizable-iframe-connected', '' );
sendResize();
} )();`;
const style = `
body { margin: 0; }
body > div { max-width: 600px; }
body.Kickstarter > div,
body.video > div { position: relative; height: 0; padding-bottom: 56.25%; }
body.Kickstarter > div > iframe,
body.video > div > iframe { position: absolute; width: 100%; height: 100%; top: 0; left: 0; }
body > div > * { margin: 0 !important;/* has to have !important to override inline styles */ max-width: 100%; }
body.Flickr > div > a { display: block; }
body.Flickr > div > a > img { width: 100%; height: auto; }
`;
const htmlDoc = (
<html lang={ document.documentElement.lang }>
<head>
<style dangerouslySetInnerHTML={ { __html: style } } />
</head>
<body data-resizable-iframe-connected="data-resizable-iframe-connected" className={ this.props.type + ' ' + this.props.provider }>
<div dangerouslySetInnerHTML={ { __html: this.props.html } } />
<script type="text/javascript" dangerouslySetInnerHTML={ { __html: observeAndResizeJS } } />
</body>
</html>
);
this.iframe.contentWindow.document.open();
this.iframe.contentWindow.document.write( '<!DOCTYPE html>' + renderToString( htmlDoc ) );
this.iframe.contentWindow.document.close();
}
}
export default OembedPreview;

View file

@ -0,0 +1,17 @@
/* ==========================================================================
oEmbed
========================================================================== */
.cf-oembed__preview {
padding: 12px;
border-width: 0 1px 1px;
border-style: solid;
border-color: $wp-color-gray-light-500;
}
.cf-oembed__frame {
display: block;
width: 100%;
max-width: 600px;
margin: 0 auto;
}

View file

@ -0,0 +1,19 @@
/**
* Internal dependencies.
*/
import withProps from '../../hocs/with-props';
import RadioField from '../radio';
import './style.scss';
export default withProps( ( props ) => {
return {
...props,
field: {
...props.field,
options: props.field.options.map( ( option ) => ( {
...option,
label: ( <img className="cf-radio-image__image" src={ option.label } /> )
} ) )
}
};
} )( RadioField );

View file

@ -0,0 +1,30 @@
/* ==========================================================================
Radio
========================================================================== */
.cf-radio__list {
.wp-block & {
list-style: none outside none;
}
}
.cf-radio__list-item {
box-sizing: border-box;
.cf-container-term-meta & {
flex: 0 0 20%;
}
.cf-container-theme-options & {
flex: 0 0 10%;
}
}
.cf-radio-image__image {
border: 1px solid $wp-color-gray-light-500;
.cf-radio__input:focus ~ .cf-radio__label &,
.cf-radio__input:checked ~ .cf-radio__label & {
outline: 4px solid $wp-color-medium-blue;
}
}

View file

@ -0,0 +1,78 @@
/**
* External dependencies.
*/
import { Component } from '@wordpress/element';
/**
* The internal dependencies.
*/
import './style.scss';
import NoOptions from '../../components/no-options';
class RadioField extends Component {
/**
* Handles the change of the input.
*
* @param {Object} e
* @return {void}
*/
handleChange = ( e ) => {
const { id, onChange } = this.props;
onChange( id, e.target.value );
}
/**
* Renders the radio options
*
* @return {Object}
*/
renderOptions() {
const {
id,
field,
value,
name
} = this.props;
return (
<ul className="cf-radio__list">
{ field.options.map( ( option, index ) => (
<li className="cf-radio__list-item" key={ index }>
<input
type="checkbox"
id={ `${ id }-${ option.value }` }
name={ name }
value={ option.value }
checked={ value === option.value }
className="cf-radio__input"
onChange={ this.handleChange }
{ ...field.attributes }
/>
<label className="cf-radio__label" htmlFor={ `${ id }-${ option.value }` }>
{ option.label }
</label>
</li>
) ) }
</ul>
);
}
/**
* Renders the component.
*
* @return {Object}
*/
render() {
const { field } = this.props;
return (
field.options.length > 0
? this.renderOptions()
: <NoOptions />
);
}
}
export default RadioField;

View file

@ -0,0 +1,73 @@
/* ==========================================================================
Radio
========================================================================== */
.cf-radio__list {
margin: 0;
.cf-radio-image & {
display: flex;
flex-wrap: wrap;
}
}
.cf-radio__list-item {
&:last-child {
margin-bottom: 0;
}
.cf-radio-image & {
flex: 0 0 20%;
position: relative;
padding: 4px;
}
}
.cf-radio__label {
.cf-container-term-meta & {
display: inline;
}
.cf-radio-image & {
display: inline-block;
}
}
.cf-radio__input {
.cf-field & {
margin-top: 0;
}
.cf-radio-image & {
position: absolute;
z-index: -1;
top: 50%;
left: 50%;
width: 0;
height: 0;
margin: 0;
outline: 0;
opacity: 0;
}
&[type="checkbox"] {
border-radius: 50%;
}
&[type="checkbox"]:checked:before {
content: "";
background-color: var(--wp-admin-theme-color, #3582c4);
border-radius: 50%;
width: 0.5rem;
height: 0.5rem;
margin: 0.1875rem;
line-height: 1.14285714;
}
}
.cf-radio-image__image {
display: block;
max-width: 100%;
padding: 5px;
box-sizing: border-box;
}

View file

@ -0,0 +1,207 @@
/**
* External dependencies.
*/
import { createRef, Component } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import {
isString,
template,
debounce
} from 'lodash';
import cx from 'classnames';
class RichTextField extends Component {
/**
* Define the project base properties
*
* @return {void}
*/
constructor() {
super();
this.node = createRef();
this.editor = null;
}
/**
* Lifecycle hook.
*
* @return {void}
*/
componentDidMount() {
if ( this.props.visible ) {
this.timer = setTimeout( this.initEditor, 250 );
const resizeObserver = new ResizeObserver( debounce( () => {
if ( this.editor ) {
/**
* On each call of the `wpAutoResize` method the global `wpActiveEditor` reference
* is changed to the element that will be resized. In some cases this is causing
* conflicts with other plugins so we need to preserve and restore the previously
* referenced element.
*/
const activeEdtior = window.wpActiveEditor;
this.editor.execCommand( 'wpAutoResize', undefined, undefined, { skip_focus: true } );
window.wpActiveEditor = activeEdtior;
}
}, 100 ) );
resizeObserver.observe( this.node.current );
this.observer = resizeObserver;
}
}
/**
* Lifecycle hook.
*
* @return {void}
*/
componentWillUnmount() {
clearTimeout( this.timer );
if ( typeof this.observer !== 'undefined' ) {
this.observer.disconnect();
}
this.destroyEditor();
}
/**
* Handles the change of the input.
*
* @param {Object|string} eventOrValue
* @return {void}
*/
handleChange = ( eventOrValue ) => {
const { id, onChange } = this.props;
onChange(
id,
isString( eventOrValue ) ? eventOrValue : eventOrValue.target.value
);
}
/**
* Renders the component.
*
* @return {Object}
*/
render() {
const {
id,
name,
value,
field
} = this.props;
const classes = [
'carbon-wysiwyg',
'wp-editor-wrap',
{ 'tmce-active': field.rich_editing },
{ 'html-active': ! field.rich_editing }
];
const mediaButtonsHTML = field.media_buttons
? template( field.media_buttons )( { id } )
: null;
const shouldRenderTabs = field.rich_editing && window.tinyMCEPreInit.qtInit[ field.settings_reference ];
return (
<div
id={ `wp-${ id }-wrap` }
className={ cx( classes ) }
ref={ this.node }
>
{ field.media_buttons && (
<div id={ `wp-${ id }-media-buttons` } className="hide-if-no-js wp-media-buttons">
<span dangerouslySetInnerHTML={ { __html: mediaButtonsHTML } }></span>
</div>
) }
{ shouldRenderTabs && (
<div className="wp-editor-tabs">
<button type="button" id={ `${ id }-tmce` } className="wp-switch-editor switch-tmce" data-wp-editor-id={ id }>
{ __( 'Visual', 'carbon-fields-ui' ) }
</button>
<button type="button" id={ `${ id }-html` } className="wp-switch-editor switch-html" data-wp-editor-id={ id }>
{ __( 'Text', 'carbon-fields-ui' ) }
</button>
</div>
) }
<div id={ `wp-${ id }-editor-container` } className="wp-editor-container">
<textarea
style={ { width: '100%' } }
className="regular-text"
id={ id }
name={ name }
value={ value }
onChange={ this.handleChange }
{ ...field.attributes }
/>
</div>
</div>
);
}
/**
* Initialize the WYSIWYG editor.
*
* @return {void}
*/
initEditor = () => {
const { id, field } = this.props;
if ( field.rich_editing ) {
const editorSetup = ( editor ) => {
this.editor = editor;
editor.on( 'blur Change', () => {
editor.save();
this.handleChange( editor.getContent() );
} );
};
const editorOptions = {
...window.tinyMCEPreInit.mceInit[ field.settings_reference ],
selector: `#${ id }`,
setup: editorSetup
};
window.tinymce.init( editorOptions );
}
const quickTagsOptions = { ...window.tinyMCEPreInit.qtInit[ field.settings_reference ] };
if ( quickTagsOptions ) {
const qtagInstance = window.quicktags( {
...quickTagsOptions,
id
} );
// Force the initialization of the quick tags.
window.QTags._buttonsInit( qtagInstance.id );
}
}
/**
* Destroy the instance of the WYSIWYG editor.
*
* @return {void}
*/
destroyEditor() {
if ( this.editor ) {
this.editor.remove();
this.node = null;
this.editor = null;
}
delete window.QTags.instances[ this.props.id ];
}
}
export default RichTextField;

View file

@ -0,0 +1,67 @@
/**
* External dependencies.
*/
import { Component } from '@wordpress/element';
import { get } from 'lodash';
/**
* The internal dependencies.
*/
import './style.scss';
import NoOptions from '../../components/no-options';
export class SelectField extends Component {
/**
* Handles the change of the input.
*
* @param {Object} e
* @return {void}
*/
handleChange = ( e ) => {
const { id, onChange } = this.props;
onChange( id, e.target.value );
}
componentMount() {
onChange( id, value );
}
/**
* Renders the component.
*
* @return {Object}
*/
render() {
const {
id,
name,
field,
onChange
} = this.props;
const value = this.props.value || get( field.options, '[0].value', '' );
return (
field.options.length > 0
? (
<select
id={ id }
name={ name }
value={ value }
className="cf-select__input"
onChange={ this.handleChange }
>
{ field.options.map( ( option ) => (
<option key={ option.value } value={ option.value }>
{ option.label }
</option>
) ) }
</select>
)
: <NoOptions />
);
}
}
export default SelectField;

View file

@ -0,0 +1,9 @@
/* ==========================================================================
Select
========================================================================== */
.cf-select__input {
display: block;
width: 100%;
margin: 0;
}

Some files were not shown because too many files have changed in this diff Show more