[Experimental] Product Filters Chips style and new interactivity API implementation (#51393)

This commit is contained in:
Tung Du 2024-09-18 13:16:07 +07:00 committed by GitHub
parent ec29880e3e
commit 1b58098848
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 987 additions and 243 deletions

View File

@ -2,47 +2,12 @@
* External dependencies * External dependencies
*/ */
import { import {
getContext as getContextFn, getContext,
store, store,
navigate as navigateFn, navigate as navigateFn,
} from '@woocommerce/interactivity'; } from '@woocommerce/interactivity';
import { getSetting } from '@woocommerce/settings'; import { getSetting } from '@woocommerce/settings';
export interface ProductFiltersContext {
isDialogOpen: boolean;
hasPageWithWordPressAdminBar: boolean;
}
const getContext = ( ns?: string ) =>
getContextFn< ProductFiltersContext >( ns );
store( 'woocommerce/product-filters', {
state: {
isDialogOpen: () => {
const context = getContext();
return context.isDialogOpen;
},
},
actions: {
openDialog: () => {
const context = getContext();
document.body.classList.add( 'wc-modal--open' );
context.hasPageWithWordPressAdminBar = Boolean(
document.getElementById( 'wpadminbar' )
);
context.isDialogOpen = true;
},
closeDialog: () => {
const context = getContext();
document.body.classList.remove( 'wc-modal--open' );
context.isDialogOpen = false;
},
},
callbacks: {},
} );
const isBlockTheme = getSetting< boolean >( 'isBlockTheme' ); const isBlockTheme = getSetting< boolean >( 'isBlockTheme' );
const isProductArchive = getSetting< boolean >( 'isProductArchive' ); const isProductArchive = getSetting< boolean >( 'isProductArchive' );
const needsRefresh = getSetting< boolean >( const needsRefresh = getSetting< boolean >(
@ -50,6 +15,28 @@ const needsRefresh = getSetting< boolean >(
false false
); );
function isParamsEqual(
obj1: Record< string, string >,
obj2: Record< string, string >
): boolean {
const keys1 = Object.keys( obj1 );
const keys2 = Object.keys( obj2 );
// First check if both objects have the same number of keys
if ( keys1.length !== keys2.length ) {
return false;
}
// Check if all keys and values are the same
for ( const key of keys1 ) {
if ( obj1[ key ] !== obj2[ key ] ) {
return false;
}
}
return true;
}
export function navigate( href: string, options = {} ) { export function navigate( href: string, options = {} ) {
/** /**
* We may need to reset the current page when changing filters. * We may need to reset the current page when changing filters.
@ -79,3 +66,58 @@ export function navigate( href: string, options = {} ) {
} }
return navigateFn( href, options ); return navigateFn( href, options );
} }
export interface ProductFiltersContext {
isDialogOpen: boolean;
hasPageWithWordPressAdminBar: boolean;
params: Record< string, string >;
originalParams: Record< string, string >;
}
store( 'woocommerce/product-filters', {
state: {
isDialogOpen: () => {
const context = getContext< ProductFiltersContext >();
return context.isDialogOpen;
},
},
actions: {
openDialog: () => {
const context = getContext< ProductFiltersContext >();
document.body.classList.add( 'wc-modal--open' );
context.hasPageWithWordPressAdminBar = Boolean(
document.getElementById( 'wpadminbar' )
);
context.isDialogOpen = true;
},
closeDialog: () => {
const context = getContext< ProductFiltersContext >();
document.body.classList.remove( 'wc-modal--open' );
context.isDialogOpen = false;
},
},
callbacks: {
maybeNavigate: () => {
const { params, originalParams } =
getContext< ProductFiltersContext >();
if ( isParamsEqual( params, originalParams ) ) {
return;
}
const url = new URL( window.location.href );
const { searchParams } = url;
for ( const key in originalParams ) {
searchParams.delete( key, originalParams[ key ] );
}
for ( const key in params ) {
searchParams.set( key, params[ key ] );
}
navigate( url.href );
},
},
} );

View File

@ -6,22 +6,15 @@ import { store, getContext } from '@woocommerce/interactivity';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { navigate } from '../../frontend'; import { ProductFiltersContext } from '../../frontend';
type ActiveFiltersContext = {
queryId: number;
params: string[];
};
store( 'woocommerce/product-filter-active', { store( 'woocommerce/product-filter-active', {
actions: { actions: {
clearAll: () => { clearAll: () => {
const { params } = getContext< ActiveFiltersContext >(); const productFiltersContext = getContext< ProductFiltersContext >(
const url = new URL( window.location.href ); 'woocommerce/product-filters'
const { searchParams } = url; );
productFiltersContext.params = {};
params.forEach( ( param ) => searchParams.delete( param ) );
navigate( url.href );
}, },
}, },
} ); } );

View File

@ -1,14 +1,12 @@
/** /**
* External dependencies * External dependencies
*/ */
import { store, getContext } from '@woocommerce/interactivity'; import { store, getContext, getElement } from '@woocommerce/interactivity';
import { DropdownContext } from '@woocommerce/interactivity-components/dropdown';
import { HTMLElementEvent } from '@woocommerce/types';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { navigate } from '../../frontend'; import { ProductFiltersContext } from '../../frontend';
type AttributeFilterContext = { type AttributeFilterContext = {
attributeSlug: string; attributeSlug: string;
@ -16,102 +14,72 @@ type AttributeFilterContext = {
selectType: 'single' | 'multiple'; selectType: 'single' | 'multiple';
}; };
interface ActiveAttributeFilterContext extends AttributeFilterContext {
value: string;
}
function nonNullable< T >( value: T ): value is NonNullable< T > {
return value !== null && value !== undefined;
}
function getUrl(
selectedTerms: string[],
slug: string,
queryType: 'or' | 'and'
) {
const url = new URL( window.location.href );
const { searchParams } = url;
if ( selectedTerms.length > 0 ) {
searchParams.set( `filter_${ slug }`, selectedTerms.join( ',' ) );
searchParams.set( `query_type_${ slug }`, queryType );
} else {
searchParams.delete( `filter_${ slug }` );
searchParams.delete( `query_type_${ slug }` );
}
return url.href;
}
function getSelectedTermsFromUrl( slug: string ) {
const url = new URL( window.location.href );
return ( url.searchParams.get( `filter_${ slug }` ) || '' )
.split( ',' )
.filter( Boolean );
}
store( 'woocommerce/product-filter-attribute', { store( 'woocommerce/product-filter-attribute', {
actions: { actions: {
navigate: () => { toggleFilter: () => {
const dropdownContext = getContext< DropdownContext >( const { ref } = getElement();
'woocommerce/interactivity-dropdown' const targetAttribute =
); ref.getAttribute( 'data-attribute-value' ) ?? 'value';
const context = getContext< AttributeFilterContext >(); const termSlug = ref.getAttribute( targetAttribute );
const filters = dropdownContext.selectedItems
.map( ( item ) => item.value )
.filter( nonNullable );
navigate( if ( ! termSlug ) return;
getUrl( filters, context.attributeSlug, context.queryType )
);
},
updateProducts: ( event: HTMLElementEvent< HTMLInputElement > ) => {
if ( ! event.target.value ) return;
const context = getContext< AttributeFilterContext >(); const { attributeSlug, queryType } =
getContext< AttributeFilterContext >();
let selectedTerms = getSelectedTermsFromUrl( const productFiltersContext = getContext< ProductFiltersContext >(
context.attributeSlug 'woocommerce/product-filters'
); );
if ( if (
event.target.checked && ! (
! selectedTerms.includes( event.target.value ) `filter_${ attributeSlug }` in productFiltersContext.params
)
) { ) {
if ( context.selectType === 'multiple' ) productFiltersContext.params = {
selectedTerms.push( event.target.value ); ...productFiltersContext.params,
if ( context.selectType === 'single' ) [ `filter_${ attributeSlug }` ]: termSlug,
selectedTerms = [ event.target.value ]; [ `query_type_${ attributeSlug }` ]: queryType,
} else { };
selectedTerms = selectedTerms.filter( return;
( value ) => value !== event.target.value
);
} }
navigate( const selectedTerms =
getUrl( productFiltersContext.params[
selectedTerms, `filter_${ attributeSlug }`
context.attributeSlug, ].split( ',' );
context.queryType if ( selectedTerms.includes( termSlug ) ) {
) const remainingSelectedTerms = selectedTerms.filter(
); ( term ) => term !== termSlug
}, );
removeFilter: () => { if ( remainingSelectedTerms.length > 0 ) {
const { attributeSlug, queryType, value } = productFiltersContext.params[
getContext< ActiveAttributeFilterContext >(); `filter_${ attributeSlug }`
] = remainingSelectedTerms.join( ',' );
} else {
const updatedParams = productFiltersContext.params;
let selectedTerms = getSelectedTermsFromUrl( attributeSlug ); delete updatedParams[ `filter_${ attributeSlug }` ];
delete updatedParams[ `query_type_${ attributeSlug }` ];
selectedTerms = selectedTerms.filter( ( item ) => item !== value ); productFiltersContext.params = updatedParams;
}
navigate( getUrl( selectedTerms, attributeSlug, queryType ) ); } else {
productFiltersContext.params[ `filter_${ attributeSlug }` ] =
selectedTerms.concat( termSlug ).join( ',' );
}
}, },
clearFilters: () => { clearFilters: () => {
const { attributeSlug, queryType } = const { attributeSlug } = getContext< AttributeFilterContext >();
getContext< ActiveAttributeFilterContext >(); const productFiltersContext = getContext< ProductFiltersContext >(
'woocommerce/product-filters'
);
const updatedParams = productFiltersContext.params;
navigate( getUrl( [], attributeSlug, queryType ) ); delete updatedParams[ `filter_${ attributeSlug }` ];
delete updatedParams[ `query_type_${ attributeSlug }` ];
productFiltersContext.params = updatedParams;
}, },
}, },
} ); } );

View File

@ -24,6 +24,7 @@ import {
import './style.scss'; import './style.scss';
import './editor.scss'; import './editor.scss';
import { EditProps } from './types'; import { EditProps } from './types';
import { getColorClasses, getColorVars } from './utils';
const Edit = ( props: EditProps ): JSX.Element => { const Edit = ( props: EditProps ): JSX.Element => {
const { const {
@ -51,21 +52,9 @@ const Edit = ( props: EditProps ): JSX.Element => {
const blockProps = useBlockProps( { const blockProps = useBlockProps( {
className: clsx( 'wc-block-product-filter-checkbox-list', { className: clsx( 'wc-block-product-filter-checkbox-list', {
'is-loading': isLoading, 'is-loading': isLoading,
'has-option-element-border-color': ...getColorClasses( attributes ),
optionElementBorder.color || customOptionElementBorder,
'has-option-element-selected-color':
optionElementSelected.color || customOptionElementSelected,
'has-option-element-color':
optionElement.color || customOptionElement,
} ), } ),
style: { style: getColorVars( attributes ),
'--wc-product-filter-checkbox-list-option-element-border':
optionElementBorder.color || customOptionElementBorder,
'--wc-product-filter-checkbox-list-option-element-selected':
optionElementSelected.color || customOptionElementSelected,
'--wc-product-filter-checkbox-list-option-element':
optionElement.color || customOptionElement,
},
} ); } );
const loadingState = useMemo( () => { const loadingState = useMemo( () => {
@ -131,9 +120,9 @@ const Edit = ( props: EditProps ): JSX.Element => {
) ) } ) ) }
</ul> </ul>
{ ! isLoading && isLongList && ( { ! isLoading && isLongList && (
<span className="wc-block-product-filter-checkbox-list__show-more"> <button className="wc-block-product-filter-checkbox-list__show-more">
<small>{ __( 'Show more…', 'woocommerce' ) }</small> { __( 'Show more…', 'woocommerce' ) }
</span> </button>
) } ) }
</div> </div>
<InspectorControls group="color"> <InspectorControls group="color">

View File

@ -11,10 +11,12 @@ import { registerBlockType } from '@wordpress/blocks';
import metadata from './block.json'; import metadata from './block.json';
import Edit from './edit'; import Edit from './edit';
import './style.scss'; import './style.scss';
import Save from './save';
if ( isExperimentalBlocksEnabled() ) { if ( isExperimentalBlocksEnabled() ) {
registerBlockType( metadata, { registerBlockType( metadata, {
edit: Edit, edit: Edit,
icon: productFilterOptions, icon: productFilterOptions,
save: Save,
} ); } );
} }

View File

@ -0,0 +1,35 @@
/**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
import clsx from 'clsx';
/**
* Internal dependencies
*/
import { BlockAttributes } from './types';
import { getColorClasses, getColorVars } from './utils';
const Save = ( {
attributes,
style,
}: {
attributes: BlockAttributes;
style: Record< string, string >;
} ) => {
const blockProps = useBlockProps.save( {
className: clsx(
'wc-block-product-filter-checkbox-list',
attributes.className,
getColorClasses( attributes )
),
style: {
...style,
...getColorVars( attributes ),
},
} );
return <div { ...blockProps } />;
};
export default Save;

View File

@ -4,11 +4,6 @@
padding: 0; padding: 0;
} }
.wc-block-product-filter-checkbox-list__item.hidden {
display: none;
}
:where(.wc-block-product-filter-checkbox-list__label) { :where(.wc-block-product-filter-checkbox-list__label) {
align-items: center; align-items: center;
display: flex; display: flex;
@ -34,6 +29,7 @@
width: 1em; width: 1em;
height: 1em; height: 1em;
border-radius: 2px; border-radius: 2px;
pointer-events: none;
.has-option-element-color & { .has-option-element-color & {
display: none; display: none;
@ -51,6 +47,7 @@
margin: 0; margin: 0;
width: 1em; width: 1em;
background: var(--wc-product-filter-checkbox-list-option-element, transparent); background: var(--wc-product-filter-checkbox-list-option-element, transparent);
cursor: pointer;
} }
.wc-block-product-filter-checkbox-list__input:checked + .wc-block-product-filter-checkbox-list__mark { .wc-block-product-filter-checkbox-list__input:checked + .wc-block-product-filter-checkbox-list__mark {
@ -75,12 +72,15 @@
color: var(--wc-product-filter-checkbox-list-option-element-selected, currentColor); color: var(--wc-product-filter-checkbox-list-option-element-selected, currentColor);
} }
:where(.wc-block-product-filter-checkbox-list__text) {
font-size: 0.875em;
}
:where(.wc-block-product-filter-checkbox-list__show-more) { :where(.wc-block-product-filter-checkbox-list__show-more) {
cursor: pointer;
text-decoration: underline; text-decoration: underline;
} appearance: none;
background: transparent;
.wc-block-product-filter-checkbox-list__show-more.hidden { border: none;
display: none; padding: 0;
} }

View File

@ -0,0 +1,66 @@
/**
* Internal dependencies
*/
import { BlockAttributes } from './types';
function getCSSVar( slug: string | undefined, value: string | undefined ) {
if ( slug ) {
return `var(--wp--preset--color--${ slug })`;
}
return value || '';
}
export function getColorVars( attributes: BlockAttributes ) {
const {
optionElement,
optionElementBorder,
optionElementSelected,
customOptionElement,
customOptionElementBorder,
customOptionElementSelected,
} = attributes;
const vars: Record< string, string > = {
'--wc-product-filter-checkbox-list-option-element': getCSSVar(
optionElement,
customOptionElement
),
'--wc-product-filter-checkbox-list-option-element-border': getCSSVar(
optionElementBorder,
customOptionElementBorder
),
'--wc-product-filter-checkbox-list-option-element-selected': getCSSVar(
optionElementSelected,
customOptionElementSelected
),
};
return Object.keys( vars ).reduce(
( acc: Record< string, string >, key ) => {
if ( vars[ key ] ) {
acc[ key ] = vars[ key ];
}
return acc;
},
{}
);
}
export function getColorClasses( attributes: BlockAttributes ) {
const {
optionElement,
optionElementBorder,
optionElementSelected,
customOptionElement,
customOptionElementBorder,
customOptionElementSelected,
} = attributes;
return {
'has-option-element-color': optionElement || customOptionElement,
'has-option-element-border-color':
optionElementBorder || customOptionElementBorder,
'has-option-element-selected-color':
optionElementSelected || customOptionElementSelected,
};
}

View File

@ -15,8 +15,44 @@
], ],
"supports": {}, "supports": {},
"usesContext": [ "usesContext": [
"filterData", "filterData"
"isParentSelected"
], ],
"attributes": {} "attributes": {
"chipText":{
"type": "string"
},
"customChipText":{
"type": "string"
},
"chipBackground":{
"type": "string"
},
"customChipBackground":{
"type": "string"
},
"chipBorder":{
"type": "string"
},
"customChipBorder":{
"type": "string"
},
"selectedChipText":{
"type": "string"
},
"customSelectedChipText":{
"type": "string"
},
"selectedChipBackground":{
"type": "string"
},
"customSelectedChipBackground":{
"type": "string"
},
"selectedChipBorder":{
"type": "string"
},
"customSelectedChipBorder":{
"type": "string"
}
}
} }

View File

@ -1,15 +1,260 @@
/** /**
* External dependencies * External dependencies
*/ */
import { useBlockProps } from '@wordpress/block-editor'; import { __ } from '@wordpress/i18n';
import { useMemo } from '@wordpress/element';
import clsx from 'clsx';
import {
InspectorControls,
useBlockProps,
withColors,
// @ts-expect-error - no types.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalColorGradientSettingsDropdown as ColorGradientSettingsDropdown,
// @ts-expect-error - no types.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalUseMultipleOriginColorsAndGradients as useMultipleOriginColorsAndGradients,
} from '@wordpress/block-editor';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import './style.scss'; import { EditProps } from './types';
import './editor.scss';
import { getColorClasses, getColorVars } from './utils';
const Edit = () => { const Edit = ( props: EditProps ): JSX.Element => {
return <div { ...useBlockProps() }>These are chips.</div>; const colorGradientSettings = useMultipleOriginColorsAndGradients();
const {
context,
clientId,
attributes,
setAttributes,
chipText,
setChipText,
chipBackground,
setChipBackground,
chipBorder,
setChipBorder,
selectedChipText,
setSelectedChipText,
selectedChipBackground,
setSelectedChipBackground,
selectedChipBorder,
setSelectedChipBorder,
} = props;
const {
customChipText,
customChipBackground,
customChipBorder,
customSelectedChipText,
customSelectedChipBackground,
customSelectedChipBorder,
} = attributes;
const { filterData } = context;
const { isLoading, items } = filterData;
const blockProps = useBlockProps( {
className: clsx( 'wc-block-product-filter-chips', {
'is-loading': isLoading,
...getColorClasses( attributes ),
} ),
style: getColorVars( attributes ),
} );
const loadingState = useMemo( () => {
return [ ...Array( 10 ) ].map( ( _, i ) => (
<div
className="wc-block-product-filter-chips__item"
key={ i }
style={ {
/* stylelint-disable */
width: Math.floor( Math.random() * ( 100 - 25 ) ) + '%',
} }
>
&nbsp;
</div>
) );
}, [] );
if ( ! items ) {
return <></>;
}
const threshold = 15;
const isLongList = items.length > threshold;
return (
<>
<div { ...blockProps }>
<div className="wc-block-product-filter-chips__items">
{ isLoading && loadingState }
{ ! isLoading &&
( isLongList
? items.slice( 0, threshold )
: items
).map( ( item, index ) => (
<div
key={ index }
className="wc-block-product-filter-chips__item"
aria-checked={ !! item.selected }
>
<span className="wc-block-product-filter-chips__label">
{ item.label }
</span>
</div>
) ) }
</div>
{ ! isLoading && isLongList && (
<button className="wc-block-product-filter-chips__show-more">
{ __( 'Show more…', 'woocommerce' ) }
</button>
) }
</div>
<InspectorControls group="color">
{ colorGradientSettings.hasColorsOrGradients && (
<ColorGradientSettingsDropdown
__experimentalIsRenderedInSidebar
settings={ [
{
label: __(
'Unselected Chip Text',
'woocommerce'
),
colorValue: chipText.color || customChipText,
onColorChange: ( colorValue: string ) => {
setChipText( colorValue );
setAttributes( {
customChipText: colorValue,
} );
},
resetAllFilter: () => {
setChipText( '' );
setAttributes( {
customChipText: '',
} );
},
},
{
label: __(
'Unselected Chip Border',
'woocommerce'
),
colorValue:
chipBorder.color || customChipBorder,
onColorChange: ( colorValue: string ) => {
setChipBorder( colorValue );
setAttributes( {
customChipBorder: colorValue,
} );
},
resetAllFilter: () => {
setChipBorder( '' );
setAttributes( {
customChipBorder: '',
} );
},
},
{
label: __(
'Unselected Chip Background',
'woocommerce'
),
colorValue:
chipBackground.color ||
customChipBackground,
onColorChange: ( colorValue: string ) => {
setChipBackground( colorValue );
setAttributes( {
customChipBackground: colorValue,
} );
},
resetAllFilter: () => {
setChipBackground( '' );
setAttributes( {
customChipBackground: '',
} );
},
},
{
label: __(
'Selected Chip Text',
'woocommerce'
),
colorValue:
selectedChipText.color ||
customSelectedChipText,
onColorChange: ( colorValue: string ) => {
setSelectedChipText( colorValue );
setAttributes( {
customSelectedChipText: colorValue,
} );
},
resetAllFilter: () => {
setSelectedChipText( '' );
setAttributes( {
customSelectedChipText: '',
} );
},
},
{
label: __(
'Selected Chip Border',
'woocommerce'
),
colorValue:
selectedChipBorder.color ||
customSelectedChipBorder,
onColorChange: ( colorValue: string ) => {
setSelectedChipBorder( colorValue );
setAttributes( {
customSelectedChipBorder: colorValue,
} );
},
resetAllFilter: () => {
setSelectedChipBorder( '' );
setAttributes( {
customSelectedChipBorder: '',
} );
},
},
{
label: __(
'Selected Chip Background',
'woocommerce'
),
colorValue:
selectedChipBackground.color ||
customSelectedChipBackground,
onColorChange: ( colorValue: string ) => {
setSelectedChipBackground( colorValue );
setAttributes( {
customSelectedChipBackground:
colorValue,
} );
},
resetAllFilter: () => {
setSelectedChipBackground( '' );
setAttributes( {
customSelectedChipBackground: '',
} );
},
},
] }
panelId={ clientId }
{ ...colorGradientSettings }
/>
) }
</InspectorControls>
</>
);
}; };
export default Edit; export default withColors( {
chipText: 'chip-text',
chipBorder: 'chip-border',
chipBackground: 'chip-background',
selectedChipText: 'selected-chip-text',
selectedChipBorder: 'selected-chip-border',
selectedChipBackground: 'selected-chip-background',
} )( Edit );

View File

@ -0,0 +1,6 @@
.wc-block-product-filter-chips.is-loading {
.wc-block-product-filter-chips__item {
@include placeholder();
margin: 5px 0;
}
}

View File

@ -0,0 +1,46 @@
/**
* External dependencies
*/
import { getElement, getContext, store } from '@woocommerce/interactivity';
/**
* Internal dependencies
*/
export type ChipsContext = {
items: {
id: string;
label: string;
value: string;
checked: boolean;
}[];
showAll: boolean;
};
store( 'woocommerce/product-filter-chips', {
actions: {
showAllItems: () => {
const context = getContext< ChipsContext >();
context.showAll = true;
},
selectItem: () => {
const { ref } = getElement();
const value = ref.getAttribute( 'value' );
if ( ! value ) return;
const context = getContext< ChipsContext >();
context.items = context.items.map( ( item ) => {
if ( item.value.toString() === value ) {
return {
...item,
checked: ! item.checked,
};
}
return item;
} );
},
},
} );

View File

@ -10,11 +10,13 @@ import { registerBlockType } from '@wordpress/blocks';
*/ */
import metadata from './block.json'; import metadata from './block.json';
import Edit from './edit'; import Edit from './edit';
import Save from './save';
import './style.scss'; import './style.scss';
if ( isExperimentalBlocksEnabled() ) { if ( isExperimentalBlocksEnabled() ) {
registerBlockType( metadata, { registerBlockType( metadata, {
edit: Edit, edit: Edit,
icon: productFilterOptions, icon: productFilterOptions,
save: Save,
} ); } );
} }

View File

@ -0,0 +1,35 @@
/**
* External dependencies
*/
import { useBlockProps } from '@wordpress/block-editor';
import clsx from 'clsx';
/**
* Internal dependencies
*/
import { BlockAttributes } from './types';
import { getColorClasses, getColorVars } from './utils';
const Save = ( {
attributes,
style,
}: {
attributes: BlockAttributes;
style: Record< string, string >;
} ) => {
const blockProps = useBlockProps.save( {
className: clsx(
'wc-block-product-filter-chips',
attributes.className,
getColorClasses( attributes )
),
style: {
...style,
...getColorVars( attributes ),
},
} );
return <div { ...blockProps } />;
};
export default Save;

View File

@ -1,3 +1,55 @@
:where(.wc-block-product-filter-chips) { :where(.wc-block-product-filter-chips__items) {
// WIP display: flex;
flex-wrap: wrap;
gap: $gap-smallest;
}
:where(.wc-block-product-filter-chips__item) {
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
padding: $gap-smallest $gap-smaller;
appearance: none;
background: transparent;
border-radius: 2px;
font-size: 0.875em;
cursor: pointer;
.has-chip-text & {
color: var(--wc-product-filter-chips-text);
}
.has-chip-background & {
background: var(--wc-product-filter-chips-background);
}
.has-chip-border & {
border-color: var(--wc-product-filter-chips-border);
}
}
:where(.wc-block-product-filter-chips__item[aria-checked="true"]) {
background: currentColor;
.has-selected-chip-text & {
color: var(--wc-product-filter-chips-selected-text);
}
.has-selected-chip-background & {
background: var(--wc-product-filter-chips-selected-background);
}
.has-selected-chip-border & {
border-color: var(--wc-product-filter-chips-selected-border);
}
}
:where(
.wc-block-product-filter-chips:not(.has-selected-chip-text)
.wc-block-product-filter-chips__item[aria-checked="true"]
> .wc-block-product-filter-chips__label
) {
filter: invert(100%);
}
:where(.wc-block-product-filter-chips__show-more) {
text-decoration: underline;
appearance: none;
background: transparent;
border: none;
padding: 0;
} }

View File

@ -6,9 +6,44 @@ import { BlockEditProps } from '@wordpress/blocks';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { FilterBlockContext } from '../../types';
export type Color = {
slug?: string;
name?: string;
class?: string;
color: string;
};
export type BlockAttributes = { export type BlockAttributes = {
className: string; className: string;
chipText?: string;
customChipText?: string;
chipBackground?: string;
customChipBackground?: string;
chipBorder?: string;
customChipBorder?: string;
selectedChipText?: string;
customSelectedChipText?: string;
selectedChipBackground?: string;
customSelectedChipBackground?: string;
selectedChipBorder?: string;
customSelectedChipBorder?: string;
}; };
export type EditProps = BlockEditProps< BlockAttributes >; export type EditProps = BlockEditProps< BlockAttributes > & {
style: Record< string, string >;
context: FilterBlockContext;
chipText: Color;
setChipText: ( value: string ) => void;
chipBackground: Color;
setChipBackground: ( value: string ) => void;
chipBorder: Color;
setChipBorder: ( value: string ) => void;
selectedChipText: Color;
setSelectedChipText: ( value: string ) => void;
selectedChipBackground: Color;
setSelectedChipBackground: ( value: string ) => void;
selectedChipBorder: Color;
setSelectedChipBorder: ( value: string ) => void;
};

View File

@ -0,0 +1,91 @@
/**
* Internal dependencies
*/
import { BlockAttributes } from './types';
function getCSSVar( slug: string | undefined, value: string | undefined ) {
if ( slug ) {
return `var(--wp--preset--color--${ slug })`;
}
return value || '';
}
export function getColorVars( attributes: BlockAttributes ) {
const {
chipText,
chipBackground,
chipBorder,
selectedChipText,
selectedChipBackground,
selectedChipBorder,
customChipText,
customChipBackground,
customChipBorder,
customSelectedChipText,
customSelectedChipBackground,
customSelectedChipBorder,
} = attributes;
const vars: Record< string, string > = {
'--wc-product-filter-chips-text': getCSSVar( chipText, customChipText ),
'--wc-product-filter-chips-background': getCSSVar(
chipBackground,
customChipBackground
),
'--wc-product-filter-chips-border': getCSSVar(
chipBorder,
customChipBorder
),
'--wc-product-filter-chips-selected-text': getCSSVar(
selectedChipText,
customSelectedChipText
),
'--wc-product-filter-chips-selected-background': getCSSVar(
selectedChipBackground,
customSelectedChipBackground
),
'--wc-product-filter-chips-selected-border': getCSSVar(
selectedChipBorder,
customSelectedChipBorder
),
};
return Object.keys( vars ).reduce(
( acc: Record< string, string >, key ) => {
if ( vars[ key ] ) {
acc[ key ] = vars[ key ];
}
return acc;
},
{}
);
}
export function getColorClasses( attributes: BlockAttributes ) {
const {
chipText,
chipBackground,
chipBorder,
selectedChipText,
selectedChipBackground,
selectedChipBorder,
customChipText,
customChipBackground,
customChipBorder,
customSelectedChipText,
customSelectedChipBackground,
customSelectedChipBorder,
} = attributes;
return {
'has-chip-text-color': chipText || customChipText,
'has-chip-background-color': chipBackground || customChipBackground,
'has-chip-border-color': chipBorder || customChipBorder,
'has-selected-chip-text-color':
selectedChipText || customSelectedChipText,
'has-selected-chip-background-color':
selectedChipBackground || customSelectedChipBackground,
'has-selected-chip-border-color':
selectedChipBorder || customSelectedChipBorder,
};
}

View File

@ -0,0 +1,5 @@
Significance: patch
Type: add
Comment: [Experimental] Product Filters Chips style and new interactivity API implementation

View File

@ -48,15 +48,9 @@ final class ProductFilterActive extends AbstractBlock {
*/ */
$active_filters = apply_filters( 'collection_active_filters_data', array(), $this->get_filter_query_params( $query_id ) ); $active_filters = apply_filters( 'collection_active_filters_data', array(), $this->get_filter_query_params( $query_id ) );
$context = array(
'queryId' => $query_id,
'params' => array_keys( $this->get_filter_query_params( $query_id ) ),
);
$wrapper_attributes = get_block_wrapper_attributes( $wrapper_attributes = get_block_wrapper_attributes(
array( array(
'data-wc-interactive' => wp_json_encode( array( 'namespace' => $this->get_full_block_name() ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), 'data-wc-interactive' => wp_json_encode( array( 'namespace' => $this->get_full_block_name() ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ),
'data-wc-context' => wp_json_encode( $context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ),
) )
); );

View File

@ -130,10 +130,10 @@ final class ProductFilterAttribute extends AbstractBlock {
return array( return array(
'title' => $term_object->name, 'title' => $term_object->name,
'attributes' => array( 'attributes' => array(
'data-wc-on--click' => "$action_namespace::actions.removeFilter", 'value' => $term,
'data-wc-on--click' => "$action_namespace::actions.toggleFilter",
'data-wc-context' => "$action_namespace::" . wp_json_encode( 'data-wc-context' => "$action_namespace::" . wp_json_encode(
array( array(
'value' => $term,
'attributeSlug' => $product_attribute, 'attributeSlug' => $product_attribute,
'queryType' => get_query_var( "query_type_{$product_attribute}" ), 'queryType' => get_query_var( "query_type_{$product_attribute}" ),
), ),
@ -228,8 +228,8 @@ final class ProductFilterAttribute extends AbstractBlock {
); );
$filter_context = array( $filter_context = array(
'on_change' => "{$this->get_full_block_name()}::actions.updateProducts", 'action' => "{$this->get_full_block_name()}::actions.toggleFilter",
'items' => $filtered_options, 'items' => $filtered_options,
); );
foreach ( $block->parsed_block['innerBlocks'] as $inner_block ) { foreach ( $block->parsed_block['innerBlocks'] as $inner_block ) {
@ -395,22 +395,25 @@ final class ProductFilterAttribute extends AbstractBlock {
' '
<!-- wp:woocommerce/product-filter-attribute {"attributeId":{{attribute_id}}} --> <!-- wp:woocommerce/product-filter-attribute {"attributeId":{{attribute_id}}} -->
<div class="wp-block-woocommerce-product-filter-attribute"> <div class="wp-block-woocommerce-product-filter-attribute">
<!-- wp:group {"metadata":{"name":"Header"},"style":{"spacing":{"blockGap":"0"}},"layout":{"type":"flex","flexWrap":"nowrap"}} --> <!-- wp:group {"metadata":{"name":"Header"},"style":{"spacing":{"blockGap":"0"}},"layout":{"type":"flex","flexWrap":"nowrap"}} -->
<div class="wp-block-group"> <div class="wp-block-group">
<!-- wp:heading {"level":3} --> <!-- wp:heading {"level":3} -->
<h3 class="wp-block-heading">{{attribute_label}}</h3> <h3 class="wp-block-heading">{{attribute_label}}</h3>
<!-- /wp:heading --> <!-- /wp:heading -->
<!-- wp:woocommerce/product-filter-clear-button {"lock":{"remove":true}} --> <!-- wp:woocommerce/product-filter-clear-button {"lock":{"remove":true}} -->
<!-- wp:buttons {"layout":{"type":"flex"}} --> <!-- wp:buttons {"layout":{"type":"flex"}} -->
<div class="wp-block-buttons"><!-- wp:button {"className":"wc-block-product-filter-clear-button is-style-outline","style":{"border":{"width":"0px","style":"none"},"typography":{"textDecoration":"underline"},"outline":"none","fontSize":"medium"}} --> <div class="wp-block-buttons"><!-- wp:button {"className":"wc-block-product-filter-clear-button is-style-outline","style":{"border":{"width":"0px","style":"none"},"typography":{"textDecoration":"underline"},"outline":"none","fontSize":"medium"}} -->
<div class="wp-block-button wc-block-product-filter-clear-button is-style-outline" style="text-decoration:underline"><a class="wp-block-button__link wp-element-button" style="border-style:none;border-width:0px">Clear</a></div> <div class="wp-block-button wc-block-product-filter-clear-button is-style-outline" style="text-decoration:underline"><a class="wp-block-button__link wp-element-button" style="border-style:none;border-width:0px">Clear</a></div>
<!-- /wp:button --></div> <!-- /wp:button --></div>
<!-- /wp:buttons --> <!-- /wp:buttons -->
<!-- /wp:woocommerce/product-filter-clear-button --></div> <!-- /wp:woocommerce/product-filter-clear-button --></div>
<!-- /wp:group --> <!-- /wp:group -->
<!-- wp:woocommerce/product-filter-checkbox-list {"lock":{"remove":true}} -->
<div class="wp-block-woocommerce-product-filter-checkbox-list wc-block-product-filter-checkbox-list"></div>
<!-- /wp:woocommerce/product-filter-checkbox-list -->
<!-- wp:woocommerce/product-filter-checkbox-list {"lock":{"remove":true},"className":"wp-block-woocommerce-product-filter-checkbox-list"} /-->
</div> </div>
<!-- /wp:woocommerce/product-filter-attribute --> <!-- /wp:woocommerce/product-filter-attribute -->
', ',

View File

@ -27,29 +27,16 @@ final class ProductFilterCheckboxList extends AbstractBlock {
$context = $block->context['filterData']; $context = $block->context['filterData'];
$items = $context['items'] ?? array(); $items = $context['items'] ?? array();
$checkbox_list_context = array( 'items' => $items ); $checkbox_list_context = array( 'items' => $items );
$on_change = $context['on_change'] ?? ''; $action = $context['action'] ?? '';
$namespace = wp_json_encode( array( 'namespace' => 'woocommerce/product-filter-checkbox-list' ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ); $namespace = wp_json_encode( array( 'namespace' => 'woocommerce/product-filter-checkbox-list' ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP );
$classes = '';
$style = '';
$classes = array( $tags = new \WP_HTML_Tag_Processor( $content );
'has-option-element-border-color' => $this->get_color_attribute_value( 'optionElementBorder', $attributes ), if ( $tags->next_tag( array( 'class_name' => 'wc-block-product-filter-checkbox-list' ) ) ) {
'has-option-element-selected-color' => $this->get_color_attribute_value( 'optionElementSelected', $attributes ), $classes = $tags->get_attribute( 'class' );
'has-option-element-color' => $this->get_color_attribute_value( 'optionElement', $attributes ), $style = $tags->get_attribute( 'style' );
); }
$classes = array_filter( $classes );
$styles = array(
'--wc-product-filter-checkbox-list-option-element-border' => $this->get_color_attribute_value( 'optionElementBorder', $attributes ),
'--wc-product-filter-checkbox-list-option-element-selected' => $this->get_color_attribute_value( 'optionElementSelected', $attributes ),
'--wc-product-filter-checkbox-list-option-element' => $this->get_color_attribute_value( 'optionElement', $attributes ),
);
$style = array_reduce(
array_keys( $styles ),
function ( $acc, $key ) use ( $styles ) {
if ( $styles[ $key ] ) {
return $acc . "{$key}: var( --wp--preset--color--{$styles[$key]} );";
}
}
);
$checked_items = array_filter( $checked_items = array_filter(
$items, $items,
@ -64,7 +51,7 @@ final class ProductFilterCheckboxList extends AbstractBlock {
$wrapper_attributes = array( $wrapper_attributes = array(
'data-wc-interactive' => esc_attr( $namespace ), 'data-wc-interactive' => esc_attr( $namespace ),
'data-wc-context' => wp_json_encode( $checkbox_list_context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), 'data-wc-context' => wp_json_encode( $checkbox_list_context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ),
'class' => implode( ' ', array_keys( $classes ) ), 'class' => esc_attr( $classes ),
'style' => esc_attr( $style ), 'style' => esc_attr( $style ),
); );
@ -84,8 +71,9 @@ final class ProductFilterCheckboxList extends AbstractBlock {
if ( ! $item['selected'] ) : if ( ! $item['selected'] ) :
if ( $count >= $remaining_initial_unchecked ) : if ( $count >= $remaining_initial_unchecked ) :
?> ?>
class="wc-block-product-filter-checkbox-list__item hidden" class="wc-block-product-filter-checkbox-list__item"
data-wc-class--hidden="!context.showAll" data-wc-bind--hidden="!context.showAll"
hidden
<?php else : ?> <?php else : ?>
<?php ++$count; ?> <?php ++$count; ?>
<?php endif; ?> <?php endif; ?>
@ -104,7 +92,7 @@ final class ProductFilterCheckboxList extends AbstractBlock {
aria-invalid="false" aria-invalid="false"
aria-label="<?php echo esc_attr( $i18n_label ); ?>" aria-label="<?php echo esc_attr( $i18n_label ); ?>"
data-wc-on--change--select-item="actions.selectCheckboxItem" data-wc-on--change--select-item="actions.selectCheckboxItem"
data-wc-on--change--parent-action="<?php echo esc_attr( $on_change ); ?>" data-wc-on--change--parent-action="<?php echo esc_attr( $action ); ?>"
value="<?php echo esc_attr( $item['value'] ); ?>" value="<?php echo esc_attr( $item['value'] ); ?>"
<?php checked( $item['selected'], 1 ); ?> <?php checked( $item['selected'], 1 ); ?>
> >
@ -120,36 +108,17 @@ final class ProductFilterCheckboxList extends AbstractBlock {
<?php } ?> <?php } ?>
</ul> </ul>
<?php if ( count( $items ) > $show_initially ) : ?> <?php if ( count( $items ) > $show_initially ) : ?>
<span <button
role="button"
class="wc-block-product-filter-checkbox-list__show-more" class="wc-block-product-filter-checkbox-list__show-more"
data-wc-class--hidden="context.showAll" data-wc-bind--hidden="context.showAll"
data-wc-on--click="actions.showAllItems" data-wc-on--click="actions.showAllItems"
hidden
> >
<small role="presentation"><?php echo esc_html__( 'Show more...', 'woocommerce' ); ?></small> <?php echo esc_html__( 'Show more...', 'woocommerce' ); ?>
</span> </button>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php <?php
return ob_get_clean(); return ob_get_clean();
} }
/**
* Get the color value from the color attributes.
*
* @param string $key The key of the color attribute.
* @param array $attributes The block attributes.
* @return string
*/
private function get_color_attribute_value( $key, $attributes ) {
if ( $attributes[ $key ] ) {
return $attributes[ $key ];
}
if ( $attributes[ 'custom' . ucfirst( $key ) ] ) {
return $attributes[ 'custom' . ucfirst( $key ) ];
}
return '';
}
} }

View File

@ -14,4 +14,90 @@ final class ProductFilterChips extends AbstractBlock {
* @var string * @var string
*/ */
protected $block_name = 'product-filter-chips'; protected $block_name = 'product-filter-chips';
/**
* Render the block.
*
* @param array $attributes Block attributes.
* @param string $content Block content.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
$classes = '';
$style = '';
$context = $block->context['filterData'];
$items = $context['items'] ?? array();
$checkbox_list_context = array( 'items' => $items );
$action = $context['action'] ?? '';
$namespace = wp_json_encode( array( 'namespace' => 'woocommerce/product-filter-chips' ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP );
$tags = new \WP_HTML_Tag_Processor( $content );
if ( $tags->next_tag( array( 'class_name' => 'wc-block-product-filter-chips' ) ) ) {
$classes = $tags->get_attribute( 'class' );
$style = $tags->get_attribute( 'style' );
}
$checked_items = array_filter(
$items,
function ( $item ) {
return $item['selected'];
}
);
$show_initially = $context['show_initially'] ?? 15;
$remaining_initial_unchecked = count( $checked_items ) > $show_initially ? count( $checked_items ) : $show_initially - count( $checked_items );
$count = 0;
$wrapper_attributes = array(
'data-wc-interactive' => esc_attr( $namespace ),
'data-wc-context' => wp_json_encode( $checkbox_list_context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ),
'class' => esc_attr( $classes ),
'style' => esc_attr( $style ),
);
ob_start();
?>
<div <?php echo get_block_wrapper_attributes( $wrapper_attributes ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
<div class="wc-block-product-filter-chips__items" aria-label="<?php echo esc_attr__( 'Filter Options', 'woocommerce' ); ?>">
<?php foreach ( $items as $item ) { ?>
<?php $item['id'] = $item['id'] ?? uniqid( 'chips-' ); ?>
<button
data-wc-key="<?php echo esc_attr( $item['id'] ); ?>"
<?php
if ( ! $item['selected'] ) :
if ( $count >= $remaining_initial_unchecked ) :
?>
class="wc-block-product-filter-chips__item"
data-wc-bind--hidden="!context.showAll"
hidden
<?php else : ?>
<?php ++$count; ?>
<?php endif; ?>
<?php endif; ?>
class="wc-block-product-filter-chips__item"
data-wc-on--click--select-item="actions.selectItem"
data-wc-on--click--parent-action="<?php echo esc_attr( $action ); ?>"
value="<?php echo esc_attr( $item['value'] ); ?>"
aria-checked="<?php echo $item['selected'] ? 'true' : 'false'; ?>"
>
<span class="wc-block-product-filter-chips__label">
<?php echo wp_kses_post( $item['label'] ); ?>
</span>
</button>
<?php } ?>
</div>
<?php if ( count( $items ) > $show_initially ) : ?>
<button
class="wc-block-product-filter-chips__show-more"
data-wc-bind--hidden="context.showAll"
data-wc-on--click="actions.showAllItems"
hidden
>
<?php echo esc_html__( 'Show more...', 'woocommerce' ); ?>
</button>
<?php endif; ?>
</div>
<?php
return ob_get_clean();
}
} }

View File

@ -142,11 +142,14 @@ class ProductFilters extends AbstractBlock {
array( array(
'isDialogOpen' => false, 'isDialogOpen' => false,
'hasPageWithWordPressAdminBar' => false, 'hasPageWithWordPressAdminBar' => false,
'params' => $this->get_filter_query_params( 0 ),
'originalParams' => $this->get_filter_query_params( 0 ),
), ),
JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP
) )
); );
$tags->set_attribute( 'data-wc-navigation-id', $this->generate_navigation_id( $block ) ); $tags->set_attribute( 'data-wc-navigation-id', $this->generate_navigation_id( $block ) );
$tags->set_attribute( 'data-wc-watch', 'callbacks.maybeNavigate' );
if ( if (
'always' === $attributes['overlay'] || 'always' === $attributes['overlay'] ||
@ -171,4 +174,45 @@ class ProductFilters extends AbstractBlock {
md5( wp_json_encode( $block->parsed_block['innerBlocks'] ) ) md5( wp_json_encode( $block->parsed_block['innerBlocks'] ) )
); );
} }
/**
* Parse the filter parameters from the URL.
* For now we only get the global query params from the URL. In the future,
* we should get the query params based on $query_id.
*
* @param int $query_id Query ID.
* @return array Parsed filter params.
*/
private function get_filter_query_params( $query_id ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
$parsed_url = wp_parse_url( esc_url_raw( $request_uri ) );
if ( empty( $parsed_url['query'] ) ) {
return array();
}
parse_str( $parsed_url['query'], $url_query_params );
/**
* Filters the active filter data provided by filter blocks.
*
* @since 11.7.0
*
* @param array $filter_param_keys The active filters data
* @param array $url_param_keys The query param parsed from the URL.
*
* @return array Active filters params.
*/
$filter_param_keys = array_unique( apply_filters( 'collection_filter_query_param_keys', array(), array_keys( $url_query_params ) ) );
return array_filter(
$url_query_params,
function ( $key ) use ( $filter_param_keys ) {
return in_array( $key, $filter_param_keys, true );
},
ARRAY_FILTER_USE_KEY
);
}
} }