Experimental Collection Rating Filter (#41999)

Introduce Experimental Collection Rating Filter, CheckboxList interactivity component.
This commit is contained in:
Sam Seay 2023-12-18 22:47:24 +13:00 committed by GitHub
parent bf29119032
commit 6d4c69850c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1371 additions and 104 deletions

View File

@ -25,6 +25,14 @@ const template = [
},
],
[ 'woocommerce/collection-stock-filter', {} ],
[
'core/heading',
{
content: __( 'Filter by Rating', 'woocommerce' ),
level: 3,
},
],
[ 'woocommerce/collection-rating-filter', {} ],
];
const firstAttribute = ATTRIBUTES.find( Boolean );

View File

@ -3,11 +3,14 @@
*/
import { __ } from '@wordpress/i18n';
import { InspectorControls } from '@wordpress/block-editor';
import { BlockEditProps } from '@wordpress/blocks';
import {
PanelBody,
ToggleControl,
// @ts-expect-error - no types.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControl as ToggleGroupControl,
// @ts-expect-error - no types.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
} from '@wordpress/components';
@ -16,9 +19,12 @@ import {
* Internal dependencies
*/
import { AttributeSelectControls } from './attribute-select-controls';
import { EditProps } from '../types';
import { BlockAttributes } from '../types';
export const Inspector = ( { attributes, setAttributes }: EditProps ) => {
export const Inspector = ( {
attributes,
setAttributes,
}: BlockEditProps< BlockAttributes > ) => {
const { attributeId, showCounts, queryType, displayStyle, selectType } =
attributes;
return (

View File

@ -0,0 +1,44 @@
{
"name": "woocommerce/collection-rating-filter",
"version": "1.0.0",
"title": "Collection Rating Filter",
"description": "Enable customers to filter the product collection by rating.",
"category": "woocommerce",
"keywords": [ "WooCommerce" ],
"supports": {
"interactivity": true
},
"ancestor": [ "woocommerce/collection-filters" ],
"usesContext": [ "collectionData" ],
"attributes": {
"className": {
"type": "string",
"default": ""
},
"showCounts": {
"type": "boolean",
"default": false
},
"displayStyle": {
"type": "string",
"default": "list"
},
"selectType": {
"type": "string",
"default": "multiple"
},
"isPreview": {
"type": "boolean",
"default": false
},
"queryParam": {
"type": "object",
"default": {
"calculate_rating_counts": "true"
}
}
},
"textdomain": "woocommerce",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}

View File

@ -0,0 +1,62 @@
/**
* External dependencies
*/
import { InspectorControls } from '@wordpress/block-editor';
import { BlockEditProps } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import {
PanelBody,
ToggleControl,
// @ts-expect-error - no types.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControl as ToggleGroupControl,
// @ts-expect-error - no types.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { Attributes } from '../types';
export const Inspector = ( {
attributes,
setAttributes,
}: BlockEditProps< Attributes > ) => {
const { showCounts, displayStyle } = attributes;
return (
<InspectorControls key="inspector">
<PanelBody title={ __( 'Display Settings', 'woocommerce' ) }>
<ToggleControl
label={ __( 'Display product count', 'woocommerce' ) }
checked={ showCounts }
onChange={ () =>
setAttributes( {
showCounts: ! showCounts,
} )
}
/>
<ToggleGroupControl
label={ __( 'Display Style', 'woocommerce' ) }
value={ displayStyle }
onChange={ ( value: string ) =>
setAttributes( {
displayStyle: value,
} )
}
className="wc-block-attribute-filter__display-toggle"
>
<ToggleGroupControlOption
value="list"
label={ __( 'List', 'woocommerce' ) }
/>
<ToggleGroupControlOption
value="dropdown"
label={ __( 'Dropdown', 'woocommerce' ) }
/>
</ToggleGroupControl>
</PanelBody>
</InspectorControls>
);
};

View File

@ -0,0 +1,316 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Icon, chevronDown } from '@wordpress/icons';
import classnames from 'classnames';
import { useBlockProps } from '@wordpress/block-editor';
import type { BlockEditProps } from '@wordpress/blocks';
import Rating, {
RatingValues,
} from '@woocommerce/base-components/product-rating';
import {
useQueryStateByKey,
useQueryStateByContext,
useCollectionData,
} from '@woocommerce/base-context/hooks';
import { getSettingWithCoercion } from '@woocommerce/settings';
import { isBoolean, isObject, objectHasProp } from '@woocommerce/types';
import { useState, useMemo, useEffect } from '@wordpress/element';
import { CheckboxList } from '@woocommerce/blocks-components';
import FormTokenField from '@woocommerce/base-components/form-token-field';
import { Disabled, Notice, withSpokenMessages } from '@wordpress/components';
/**
* Internal dependencies
*/
import { previewOptions } from './preview';
import './style.scss';
import { Attributes } from './types';
import { formatSlug, getActiveFilters, generateUniqueId } from './utils';
import { useSetWraperVisibility } from '../../../filter-wrapper/context';
import './editor.scss';
import { Inspector } from '../attribute-filter/components/inspector-controls';
const NoRatings = () => (
<Notice status="warning" isDismissible={ false }>
<p>
{ __(
"Your store doesn't have any products with ratings yet. This filter option will display when a product receives a review.",
'woocommerce'
) }
</p>
</Notice>
);
const Edit = ( props: BlockEditProps< Attributes > ) => {
const { className } = props.attributes;
const blockAttributes = props.attributes;
const blockProps = useBlockProps( {
className: classnames( 'wc-block-rating-filter', className ),
} );
const isEditor = true;
const setWrapperVisibility = useSetWraperVisibility();
const [ queryState ] = useQueryStateByContext();
const { results: filteredCounts, isLoading: filteredCountsLoading } =
useCollectionData( {
queryRating: true,
queryState,
isEditor,
} );
const [ displayedOptions, setDisplayedOptions ] = useState(
blockAttributes.isPreview ? previewOptions : []
);
const isLoading =
! blockAttributes.isPreview &&
filteredCountsLoading &&
displayedOptions.length === 0;
const isDisabled = ! blockAttributes.isPreview && filteredCountsLoading;
const initialFilters = useMemo(
() => getActiveFilters( 'rating_filter' ),
[]
);
const [ checked ] = useState( initialFilters );
const [ productRatingsQuery ] = useQueryStateByKey(
'rating',
initialFilters
);
/*
FormTokenField forces the dropdown to reopen on reset, so we create a unique ID to use as the components key.
This will force the component to remount on reset when we change this value.
More info: https://github.com/woocommerce/woocommerce-blocks/pull/6920#issuecomment-1222402482
*/
const [ remountKey, setRemountKey ] = useState( generateUniqueId() );
const [ displayNoProductRatingsNotice, setDisplayNoProductRatingsNotice ] =
useState( false );
const multiple = blockAttributes.selectType !== 'single';
const showChevron = multiple
? ! isLoading && checked.length < displayedOptions.length
: ! isLoading && checked.length === 0;
/**
* Compare intersection of all ratings and filtered counts to get a list of options to display.
*/
useEffect( () => {
/**
* Checks if a status slug is in the query state.
*
* @param {string} queryStatus The status slug to check.
*/
if ( filteredCountsLoading || blockAttributes.isPreview ) {
return;
}
const orderedRatings =
! filteredCountsLoading &&
objectHasProp( filteredCounts, 'rating_counts' ) &&
Array.isArray( filteredCounts.rating_counts )
? [ ...filteredCounts.rating_counts ].reverse()
: [];
if ( orderedRatings.length === 0 ) {
setDisplayedOptions( previewOptions );
setDisplayNoProductRatingsNotice( true );
return;
}
const newOptions = orderedRatings
.filter(
( item ) => isObject( item ) && Object.keys( item ).length > 0
)
.map( ( item ) => {
return {
label: (
<Rating
key={ item?.rating }
rating={ item?.rating }
ratedProductsCount={
blockAttributes.showCounts ? item?.count : null
}
/>
),
value: item?.rating?.toString(),
};
} );
setDisplayedOptions( newOptions );
setRemountKey( generateUniqueId() );
}, [
blockAttributes.showCounts,
blockAttributes.isPreview,
filteredCounts,
filteredCountsLoading,
productRatingsQuery,
] );
if ( ! filteredCountsLoading && displayedOptions.length === 0 ) {
setWrapperVisibility( false );
return null;
}
const hasFilterableProducts = getSettingWithCoercion(
'hasFilterableProducts',
false,
isBoolean
);
if ( ! hasFilterableProducts ) {
setWrapperVisibility( false );
return null;
}
setWrapperVisibility( true );
return (
<>
<Inspector { ...props } />
<div { ...blockProps }>
<Disabled>
{ displayNoProductRatingsNotice && <NoRatings /> }
<div
className={ classnames(
'wc-block-rating-filter',
`style-${ blockAttributes.displayStyle }`,
{
'is-loading': isLoading,
}
) }
>
{ blockAttributes.displayStyle === 'dropdown' ? (
<>
<FormTokenField
key={ remountKey }
className={ classnames( {
'single-selection': ! multiple,
'is-loading': isLoading,
} ) }
style={ {
borderStyle: 'none',
} }
suggestions={ displayedOptions
.filter(
( option ) =>
! checked.includes(
option.value
)
)
.map( ( option ) => option.value ) }
disabled={ isLoading }
placeholder={ __(
'Select Rating',
'woocommerce'
) }
onChange={ () => {
// noop
} }
value={ checked }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - FormTokenField doesn't accept custom components, forcing it here to display component
displayTransform={ ( value ) => {
const resultWithZeroCount = {
value,
label: (
<Rating
key={
Number(
value
) as RatingValues
}
rating={
Number(
value
) as RatingValues
}
ratedProductsCount={ 0 }
/>
),
};
const resultWithNonZeroCount =
displayedOptions.find(
( option ) =>
option.value === value
);
const displayedResult =
resultWithNonZeroCount ||
resultWithZeroCount;
const { label, value: rawValue } =
displayedResult;
// A label - JSX component - is extended with faked string methods to allow using JSX element as an option in FormTokenField
const extendedLabel = Object.assign(
{},
label,
{
toLocaleLowerCase: () =>
rawValue,
substring: (
start: number,
end: number
) =>
start === 0 && end === 1
? label
: '',
}
);
return extendedLabel;
} }
saveTransform={ formatSlug }
messages={ {
added: __(
'Rating filter added.',
'woocommerce'
),
removed: __(
'Rating filter removed.',
'woocommerce'
),
remove: __(
'Remove rating filter.',
'woocommerce'
),
__experimentalInvalid: __(
'Invalid rating filter.',
'woocommerce'
),
} }
/>
{ showChevron && (
<Icon icon={ chevronDown } size={ 30 } />
) }
</>
) : (
<CheckboxList
className={ 'wc-block-rating-filter-list' }
options={ displayedOptions }
checked={ checked }
onChange={ () => {
// noop
} }
isLoading={ isLoading }
isDisabled={ isDisabled }
/>
) }
</div>
</Disabled>
</div>
</>
);
};
export default withSpokenMessages( Edit );

View File

@ -0,0 +1,3 @@
.wc-block-rating-filter .components-notice__content {
color: $black;
}

View File

@ -0,0 +1,54 @@
/**
* External dependencies
*/
import { getContext, navigate, store } from '@woocommerce/interactivity';
import { CheckboxListContext } from '@woocommerce/interactivity-components/checkbox-list';
import { DropdownContext } from '@woocommerce/interactivity-components/dropdown';
store( 'woocommerce/collection-rating-filter', {
actions: {
onCheckboxChange: () => {
const checkboxContext = getContext< CheckboxListContext >(
'woocommerce/interactivity-checkbox-list'
);
const filters = checkboxContext.items
.filter( ( item ) => {
return item.checked;
} )
.map( ( item ) => {
return item.value;
} );
const url = new URL( window.location.href );
if ( filters.length ) {
// add filters to url
url.searchParams.set( 'rating_filter', filters.join( ',' ) );
} else {
// remove filters from url
url.searchParams.delete( 'rating_filter' );
}
navigate( url );
},
onDropdownChange: () => {
const dropdownContext = getContext< DropdownContext >(
'woocommerce/interactivity-dropdown'
);
const filter = dropdownContext.selectedItem?.value;
const url = new URL( window.location.href );
if ( filter ) {
// add filter to url
url.searchParams.set( 'rating_filter', filter );
} else {
// remove filter from url
url.searchParams.delete( 'rating_filter' );
}
navigate( url );
},
},
} );

View File

@ -0,0 +1,41 @@
/**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import { Icon, starEmpty } from '@wordpress/icons';
import classNames from 'classnames';
import { useBlockProps } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import edit from './edit';
import metadata from './block.json';
import type { Attributes } from './types';
registerBlockType( metadata, {
icon: {
src: (
<Icon
icon={ starEmpty }
className="wc-block-editor-components-block-icon"
/>
),
},
attributes: {
...metadata.attributes,
},
edit,
// Save the props to post content.
save( { attributes }: { attributes: Attributes } ) {
const { className } = attributes;
return (
<div
{ ...useBlockProps.save( {
className: classNames( 'is-loading', className ),
} ) }
/>
);
},
} );

View File

@ -0,0 +1,28 @@
/**
* External dependencies
*/
import Rating from '@woocommerce/base-components/product-rating';
export const previewOptions = [
{
label: <Rating key={ 5 } rating={ 5 } ratedProductsCount={ null } />,
value: '5',
},
{
label: <Rating key={ 4 } rating={ 4 } ratedProductsCount={ null } />,
value: '4',
},
{
label: <Rating key={ 3 } rating={ 3 } ratedProductsCount={ null } />,
value: '3',
},
{
label: <Rating key={ 2 } rating={ 2 } ratedProductsCount={ null } />,
value: '2',
},
{
label: <Rating key={ 1 } rating={ 1 } ratedProductsCount={ null } />,
value: '1',
},
];

View File

@ -0,0 +1,175 @@
@import "../../../shared/styles/style";
.wc-block-rating-filter {
&.is-loading {
@include placeholder();
margin-top: $gap;
box-shadow: none;
border-radius: 0;
}
&.style-dropdown {
@include includeFormTokenFieldFix();
position: relative;
display: flex;
gap: $gap;
align-items: flex-start;
.wc-block-components-filter-submit-button {
height: 36px;
line-height: 1;
}
> svg {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
}
.wc-block-components-product-rating__stars {
display: inline-block;
}
.wc-blocks-components-form-token-field-wrapper {
flex-grow: 1;
max-width: unset;
width: 0;
height: max-content;
&:not(.is-loading) {
border: 1px solid $gray-700 !important;
border-radius: 4px;
}
&.is-loading {
border-radius: em(4px);
}
.components-form-token-field {
border-radius: inherit;
}
}
.wc-blocks-components-form-token-field-wrapper .components-form-token-field__input-container {
@include reset-color();
@include reset-typography();
border: 0;
padding: $gap-smaller;
border-radius: inherit;
.components-form-token-field__input {
@include font-size(small);
&::placeholder {
color: $black;
}
}
.components-form-token-field__suggestions-list {
border: 1px solid $gray-700;
border-radius: 4px;
margin-top: $gap-smaller;
max-height: 21em;
.components-form-token-field__suggestion {
color: $black;
border: 1px solid $gray-400;
border-radius: 4px;
margin: $gap-small;
padding: $gap-small;
}
}
.components-form-token-field__token,
.components-form-token-field__suggestion {
@include font-size(small);
}
}
.wc-block-components-product-rating {
margin-bottom: 0;
display: flex;
}
.wc-block-components-product-rating-count {
margin-left: $gap-smallest;
}
}
.wc-blocks-components-form-token-field-wrapper:not(.single-selection) .components-form-token-field__input-container {
padding: $gap-smallest 30px $gap-smallest $gap-smaller;
.components-form-token-field__token-text {
background-color: $white;
border: 1px solid;
border-right: 0;
border-radius: 25px 0 0 25px;
padding: em($gap-smallest) em($gap-smaller) em($gap-smallest) em($gap-small);
line-height: 22px;
}
> .components-form-token-field__input {
margin: em($gap-smallest) 0;
}
.components-button.components-form-token-field__remove-token {
background-color: $white;
border: 1px solid;
border-left: 0;
border-radius: 0 25px 25px 0;
padding: 1px em($gap-smallest) 0 0;
&.has-icon svg {
background-color: $gray-200;
border-radius: 25px;
}
}
}
.wp-block-woocommerce-rating-filter {
margin-bottom: $gap-large;
.wc-block-rating-filter .wc-block-rating-filter-list li input,
.wc-block-rating-filter .wc-block-rating-filter-list li label {
cursor: pointer;
}
}
.wc-block-rating-filter__actions {
align-items: center;
display: flex;
gap: $gap;
justify-content: flex-end;
margin-top: $gap;
.wc-block-components-filter-submit-button {
margin-left: 0;
&:disabled {
opacity: 0.6;
cursor: auto;
}
}
// The specificity here is needed to overwrite the margin-top that is inherited on WC block template pages such as Shop.
button[type="submit"]:not(.wp-block-search__button).wc-block-components-filter-submit-button {
margin-left: 0;
margin-top: 0;
@include font-size(small);
}
.wc-block-rating-filter__button {
margin-top: em($gap-smaller);
padding: em($gap-smaller) em($gap);
@include font-size(small);
}
}
.editor-styles-wrapper .wc-block-rating-filter .wc-block-rating-filter__button {
margin-top: em($gap-smaller);
padding: em($gap-smaller) em($gap);
@include font-size(small);
}

View File

@ -0,0 +1,8 @@
export interface Attributes {
className?: string;
displayStyle: string;
selectType: string;
showCounts: boolean;
showFilterButton: boolean;
isPreview?: boolean;
}

View File

@ -0,0 +1,50 @@
/**
* External dependencies
*/
import { isString } from '@woocommerce/types';
import { getUrlParameter } from '@woocommerce/utils';
/**
* Internal dependencies
*/
import metadata from './block.json';
export const getActiveFilters = ( queryParamKey = 'filter_rating' ) => {
const params = getUrlParameter( queryParamKey );
if ( ! params ) {
return [];
}
const parsedParams = isString( params )
? params.split( ',' )
: ( params as string[] );
return parsedParams;
};
export function generateUniqueId() {
return Math.floor( Math.random() * Date.now() );
}
export const formatSlug = ( slug: string ) =>
slug
.trim()
.replace( /\s/g, '-' )
.replace( /_/g, '-' )
.replace( /-+/g, '-' )
.replace( /[^a-zA-Z0-9-]/g, '' );
export const parseAttributes = ( data: Record< string, unknown > ) => {
return {
showFilterButton: data?.showFilterButton === 'true',
showCounts: data?.showCounts === 'true',
isPreview: false,
displayStyle:
( isString( data?.displayStyle ) && data.displayStyle ) ||
metadata.attributes.displayStyle.default,
selectType:
( isString( data?.selectType ) && data.selectType ) ||
metadata.attributes.selectType.default,
};
};

View File

@ -109,6 +109,10 @@ const blocks = {
customDir: 'collection-filters/inner-blocks/attribute-filter',
isExperimental: true,
},
'collection-rating-filter': {
customDir: 'collection-filters/inner-blocks/rating-filter',
isExperimental: true,
},
'order-confirmation-summary': {
customDir: 'order-confirmation/summary',
},
@ -191,6 +195,12 @@ const entries = {
'./assets/js/atomic/blocks/product-elements/add-to-cart-form/index.tsx',
...getBlockEntries( '{index,block,frontend}.{t,j}s{,x}' ),
// Interactivity component styling
'wc-interactivity-checkbox-list':
'./packages/interactivity-components/checkbox-list/index.ts',
'wc-interactivity-dropdown':
'./packages/interactivity-components/dropdown/index.ts',
// Templates
'wc-blocks-classic-template-revert-button-style':
'./assets/js/templates/revert-button/index.tsx',
@ -209,6 +219,8 @@ const entries = {
// interactivity components, exported as separate entries for now
'wc-interactivity-dropdown':
'./packages/interactivity-components/dropdown/index.ts',
'wc-interactivity-checkbox-list':
'./packages/interactivity-components/checkbox-list/index.ts',
},
main: {
// Shared blocks code

View File

@ -0,0 +1,43 @@
/**
* External dependencies
*/
import { getContext, store } from '@woocommerce/interactivity';
/**
* Internal dependencies
*/
import { HTMLElementEvent } from '../../../assets/js/types';
/**
* Internal dependencies
*/
import './style.scss';
export type CheckboxListContext = {
items: {
id: string;
label: string;
value: string;
checked: boolean;
}[];
};
store( 'woocommerce/interactivity-checkbox-list', {
state: {},
actions: {
selectCheckboxItem: ( event: HTMLElementEvent< HTMLInputElement > ) => {
const context = getContext< CheckboxListContext >();
const value = event.target.value;
context.items = context.items.map( ( item ) => {
if ( item.value.toString() === value ) {
return {
...item,
checked: ! item.checked,
};
}
return item;
} );
},
},
} );

View File

@ -0,0 +1,3 @@
// Import styles we need to render the checkbox list and checkbox control.
@import "../../../packages/components/checkbox-list/style";
@import "../../../packages/components/checkbox-control/style";

View File

@ -3,6 +3,11 @@
*/
import { getContext, store } from '@woocommerce/interactivity';
/**
* Internal dependencies
*/
import './style.scss';
export type DropdownContext = {
currentItem: {
label: string;
@ -19,74 +24,74 @@ export type DropdownContext = {
isOpen: boolean;
};
store( 'woocommerce/interactivity-dropdown', {
type DropdownStore = {
state: {
get placeholderText() {
const context = getContext< DropdownContext >();
const { selectedItem } = context;
selectedItem?: {
label: string | null;
value: string | null;
};
placeholderText: string;
isSelected: boolean;
};
return selectedItem.label || 'Select an option';
},
get isSelected() {
const context = getContext< DropdownContext >();
const {
currentItem: { value },
} = context;
return (
context.selectedItem.value === value ||
context.hoveredItem.value === value
);
},
},
actions: {
toggleIsOpen: () => {
const context = getContext< DropdownContext >();
toggleIsOpen: () => void;
selectDropdownItem: ( event: MouseEvent ) => void;
};
};
context.isOpen = ! context.isOpen;
const { state } = store< DropdownStore >(
'woocommerce/interactivity-dropdown',
{
state: {
get placeholderText(): string {
const { selectedItem } = state;
return selectedItem?.label || 'Select an option';
},
get isSelected(): boolean {
const { currentItem } = getContext< DropdownContext >();
const { selectedItem } = state;
return selectedItem?.value === currentItem.value;
},
},
selectDropdownItem: ( event: MouseEvent ) => {
const context = getContext< DropdownContext >();
actions: {
toggleIsOpen: () => {
const context = getContext< DropdownContext >();
const {
currentItem: { label, value },
} = context;
context.isOpen = ! context.isOpen;
},
selectDropdownItem: ( event: MouseEvent ) => {
const context = getContext< DropdownContext >();
const { selectedItem } = state;
const { selectedItem } = context;
const {
currentItem: { label, value },
} = context;
if (
selectedItem.value === value &&
selectedItem.label === label
) {
context.selectedItem = {
label: null,
value: null,
};
} else {
context.selectedItem = { label, value };
}
if (
selectedItem?.value === value &&
selectedItem?.label === label
) {
state.selectedItem = {
label: null,
value: null,
};
context.selectedItem = {
label: null,
value: null,
};
} else {
state.selectedItem = { label, value };
context.selectedItem = { label, value };
}
context.isOpen = false;
context.isOpen = false;
event.stopPropagation();
event.stopPropagation();
},
},
addHoverClass: () => {
const context = getContext< DropdownContext >();
const {
currentItem: { label, value },
} = context;
context.hoveredItem = { label, value };
},
removeHoverClass: () => {
const context = getContext< DropdownContext >();
context.hoveredItem = {
label: null,
value: null,
};
},
},
} );
}
);

View File

@ -0,0 +1,145 @@
@import "../../../assets/js/blocks/shared/styles/style";
.wc-interactivity-dropdown {
@include includeFormTokenFieldFix();
position: relative;
display: flex;
gap: $gap;
align-items: flex-start;
.wc-block-components-filter-submit-button {
height: 36px;
line-height: 1;
}
> svg {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
.wc-blocks-components-form-token-field-wrapper {
flex-grow: 1;
max-width: unset;
width: 0;
height: max-content;
&:not(.is-loading) {
border: 1px solid $gray-700 !important;
border-radius: 4px;
}
&.is-loading {
border-radius: em(4px);
}
.components-form-token-field {
border-radius: inherit;
}
}
.wc-blocks-components-form-token-field-wrapper .components-form-token-field__input-container {
@include reset-color();
@include reset-typography();
border: 0;
padding: $gap-smaller;
border-radius: inherit;
.components-form-token-field__input {
@include font-size(small);
&::placeholder {
color: $black;
}
}
.components-form-token-field__suggestions-list {
border: 1px solid $gray-700;
border-radius: 4px;
margin-top: $gap-smaller;
max-height: 21em;
.components-form-token-field__suggestion {
color: $black;
border: 1px solid $gray-400;
border-radius: 4px;
margin: $gap-small;
padding: $gap-small;
&.is-selected,
&:hover {
background: $gray-100;
color: $gray-800;
}
}
}
.components-form-token-field__token,
.components-form-token-field__suggestion {
@include font-size(small);
}
}
.wc-block-components-product-rating {
margin-bottom: 0;
}
.wc-blocks-components-form-token-field-wrapper:not(.single-selection) .components-form-token-field__input-container {
padding: $gap-smallest 30px $gap-smallest $gap-smaller;
.components-form-token-field__token-text {
background-color: $white;
border: 1px solid;
border-right: 0;
border-radius: 25px 0 0 25px;
padding: em($gap-smallest) em($gap-smaller) em($gap-smallest) em($gap-small);
line-height: 22px;
}
> .components-form-token-field__input {
margin: em($gap-smallest) 0;
}
.components-button.components-form-token-field__remove-token {
background-color: $white;
border: 1px solid;
border-left: 0;
border-radius: 0 25px 25px 0;
padding: 1px em($gap-smallest) 0 0;
&.has-icon svg {
background-color: $gray-200;
border-radius: 25px;
}
}
}
.wc-block-stock-filter__actions {
align-items: center;
display: flex;
gap: $gap;
justify-content: flex-end;
margin-top: $gap;
// The specificity here is needed to overwrite the margin-top that is inherited on WC block template pages such as Shop.
button[type="submit"]:not(.wp-block-search__button).wc-block-components-filter-submit-button {
margin-left: 0;
margin-top: 0;
@include font-size(small);
}
.wc-block-stock-filter__button {
margin-top: em($gap-smaller);
padding: em($gap-smaller) em($gap);
@include font-size(small);
}
}
.editor-styles-wrapper .wc-block-stock-filter .wc-block-stock-filter__button {
margin-top: em($gap-smaller);
padding: em($gap-smaller) em($gap);
@include font-size(small);
}
}

View File

@ -0,0 +1,4 @@
Significance: patch
Type: add
Comment: Add experimental product rating filter block powered by interactivity API.

View File

@ -46,26 +46,29 @@ final class AssetsController {
* Register block scripts & styles.
*/
public function register_assets() {
$this->register_style( 'wc-blocks-packages-style', plugins_url( $this->api->get_block_asset_build_path( 'packages-style', 'css' ), dirname( __DIR__ ) ), [], 'all', true );
$this->register_style( 'wc-blocks-style', plugins_url( $this->api->get_block_asset_build_path( 'wc-blocks', 'css' ), dirname( __DIR__ ) ), [], 'all', true );
$this->register_style( 'wc-blocks-editor-style', plugins_url( $this->api->get_block_asset_build_path( 'wc-blocks-editor-style', 'css' ), dirname( __DIR__ ) ), [ 'wp-edit-blocks' ], 'all', true );
$this->register_style( 'wc-blocks-packages-style', plugins_url( $this->api->get_block_asset_build_path( 'packages-style', 'css' ), dirname( __DIR__ ) ), array(), 'all', true );
$this->register_style( 'wc-blocks-style', plugins_url( $this->api->get_block_asset_build_path( 'wc-blocks', 'css' ), dirname( __DIR__ ) ), array(), 'all', true );
$this->register_style( 'wc-blocks-editor-style', plugins_url( $this->api->get_block_asset_build_path( 'wc-blocks-editor-style', 'css' ), dirname( __DIR__ ) ), array( 'wp-edit-blocks' ), 'all', true );
$this->api->register_script( 'wc-blocks-middleware', 'assets/client/blocks/wc-blocks-middleware.js', [], false );
$this->api->register_script( 'wc-blocks-data-store', 'assets/client/blocks/wc-blocks-data.js', [ 'wc-blocks-middleware' ] );
$this->api->register_script( 'wc-blocks-vendors', $this->api->get_block_asset_build_path( 'wc-blocks-vendors' ), [], false );
$this->api->register_script( 'wc-blocks-registry', 'assets/client/blocks/wc-blocks-registry.js', [], false );
$this->api->register_script( 'wc-blocks', $this->api->get_block_asset_build_path( 'wc-blocks' ), [ 'wc-blocks-vendors' ], false );
$this->api->register_script( 'wc-blocks-middleware', 'assets/client/blocks/wc-blocks-middleware.js', array(), false );
$this->api->register_script( 'wc-blocks-data-store', 'assets/client/blocks/wc-blocks-data.js', array( 'wc-blocks-middleware' ) );
$this->api->register_script( 'wc-blocks-vendors', $this->api->get_block_asset_build_path( 'wc-blocks-vendors' ), array(), false );
$this->api->register_script( 'wc-blocks-registry', 'assets/client/blocks/wc-blocks-registry.js', array(), false );
$this->api->register_script( 'wc-blocks', $this->api->get_block_asset_build_path( 'wc-blocks' ), array( 'wc-blocks-vendors' ), false );
$this->api->register_script( 'wc-blocks-shared-context', 'assets/client/blocks/wc-blocks-shared-context.js' );
$this->api->register_script( 'wc-blocks-shared-hocs', 'assets/client/blocks/wc-blocks-shared-hocs.js', [], false );
$this->api->register_script( 'wc-blocks-shared-hocs', 'assets/client/blocks/wc-blocks-shared-hocs.js', array(), false );
// The price package is shared externally so has no blocks prefix.
$this->api->register_script( 'wc-price-format', 'assets/client/blocks/price-format.js', [], false );
$this->api->register_script( 'wc-price-format', 'assets/client/blocks/price-format.js', array(), false );
$this->api->register_script( 'wc-blocks-checkout', 'assets/client/blocks/blocks-checkout.js', [] );
$this->api->register_script( 'wc-blocks-components', 'assets/client/blocks/blocks-components.js', [] );
$this->api->register_script( 'wc-blocks-checkout', 'assets/client/blocks/blocks-checkout.js', array() );
$this->api->register_script( 'wc-blocks-components', 'assets/client/blocks/blocks-components.js', array() );
// Register the interactivity components here for now.
$this->api->register_script( 'wc-interactivity-dropdown', 'assets/client/blocks/wc-interactivity-dropdown.js', [] );
$this->api->register_script( 'wc-interactivity-dropdown', 'assets/client/blocks/wc-interactivity-dropdown.js', array() );
$this->api->register_script( 'wc-interactivity-checkbox-list', 'assets/client/blocks/wc-interactivity-checkbox-list.js', array() );
$this->register_style( 'wc-interactivity-checkbox-list', plugins_url( $this->api->get_block_asset_build_path( 'wc-interactivity-checkbox-list', 'css' ), __DIR__ ), array(), 'all', true );
$this->register_style( 'wc-interactivity-dropdown', plugins_url( $this->api->get_block_asset_build_path( 'wc-interactivity-dropdown', 'css' ), __DIR__ ), array(), 'all', true );
wp_add_inline_script(
'wc-blocks-middleware',
@ -103,7 +106,7 @@ final class AssetsController {
* @return array URLs to print for resource hints.
*/
public function add_resource_hints( $urls, $relation_type ) {
if ( ! in_array( $relation_type, [ 'prefetch', 'prerender' ], true ) || is_admin() ) {
if ( ! in_array( $relation_type, array( 'prefetch', 'prerender' ), true ) || is_admin() ) {
return $urls;
}
@ -137,7 +140,7 @@ final class AssetsController {
* @return array Array of URLs.
*/
private function get_prefetch_resource_hints() {
$urls = [];
$urls = array();
// Core page IDs.
$cart_page_id = wc_get_page_id( 'cart' );
@ -168,7 +171,7 @@ final class AssetsController {
* @return array Array of URLs.
*/
private function get_prerender_resource_hints() {
$urls = [];
$urls = array();
$is_block_cart = has_block( 'woocommerce/cart' );
if ( ! $is_block_cart ) {
@ -193,13 +196,13 @@ final class AssetsController {
*/
private function get_block_asset_resource_hints( $filename = '' ) {
if ( ! $filename ) {
return [];
return array();
}
$script_data = $this->api->get_script_data(
$this->api->get_block_asset_build_path( $filename )
);
$resources = array_merge(
[ esc_url( add_query_arg( 'ver', $script_data['version'], $script_data['src'] ) ) ],
array( esc_url( add_query_arg( 'ver', $script_data['version'], $script_data['src'] ) ) ),
$this->get_script_dependency_src_array( $script_data['dependencies'] )
);
return array_map(
@ -230,7 +233,7 @@ final class AssetsController {
}
return $src;
},
[]
array()
);
}
@ -289,7 +292,7 @@ final class AssetsController {
* 'all', 'print' and 'screen', or media queries like '(orientation: portrait)' and '(max-width: 640px)'.
* @param boolean $rtl Optional. Whether or not to register RTL styles.
*/
protected function register_style( $handle, $src, $deps = [], $media = 'all', $rtl = false ) {
protected function register_style( $handle, $src, $deps = array(), $media = 'all', $rtl = false ) {
$filename = str_replace( plugins_url( '/', dirname( __DIR__ ) ), '', $src );
$ver = self::get_file_version( $filename );
@ -338,7 +341,7 @@ final class AssetsController {
*/
public function update_block_settings_dependencies() {
$wp_scripts = wp_scripts();
$known_packages = [ 'wc-settings', 'wc-blocks-checkout', 'wc-price-format' ];
$known_packages = array( 'wc-settings', 'wc-blocks-checkout', 'wc-price-format' );
foreach ( $wp_scripts->registered as $handle => $script ) {
// scripts that are loaded in the footer has extra->group = 1.

View File

@ -0,0 +1,175 @@
<?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\InteractivityComponents\CheckboxList;
use Automattic\WooCommerce\Blocks\InteractivityComponents\Dropdown;
/**
* Collection Rating Filter Block
*
* @package Automattic\WooCommerce\Blocks\BlockTypes
*/
final class CollectionRatingFilter extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'collection-rating-filter';
const RATING_FILTER_QUERY_VAR = 'rating_filter';
/**
* Include and render the block.
*
* @param array $attributes Block attributes. Default empty array.
* @param string $content Block content. Default empty string.
* @param WP_Block $block Block instance.
* @return string Rendered block type output.
*/
protected function render( $attributes, $content, $block ) {
// don't render if its admin, or ajax in progress.
if ( is_admin() || wp_doing_ajax() ) {
return '';
}
$rating_counts = $block->context['collectionData']['rating_counts'] ?? array();
$display_style = $attributes['displayStyle'] ?? 'list';
$show_counts = $attributes['showCounts'] ?? false;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verification is not required here.
$selected_ratings_query_param = isset( $_GET[ self::RATING_FILTER_QUERY_VAR ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::RATING_FILTER_QUERY_VAR ] ) ) : '';
$wrapper_attributes = get_block_wrapper_attributes(
array(
'data-wc-interactive' => 'woocommerce/collection-rating-filter',
'class' => 'wc-block-rating-filter',
)
);
$input = 'list' === $display_style ? CheckboxList::render(
array(
'items' => $this->get_checkbox_list_items( $rating_counts, $selected_ratings_query_param, $show_counts ),
'on_change' => 'woocommerce/collection-rating-filter::actions.onCheckboxChange',
)
) : Dropdown::render(
$this->get_dropdown_props( $rating_counts, $selected_ratings_query_param, $show_counts )
);
return sprintf(
'<div %1$s>
<div class="wc-block-rating-filter__controls">%2$s</div>
<div class="wc-block-rating-filter__actions"></div>
</div>',
$wrapper_attributes,
$input
);
}
/**
* Render the rating label.
*
* @param int $rating The rating to render.
* @param string $count_label The count label to render.
* @return string|false
*/
private function render_rating_label( $rating, $count_label ) {
$width = $rating * 20;
$rating_label = sprintf(
/* translators: %1$d is referring to rating value. Example: Rated 4 out of 5. */
__( 'Rated %1$d out of 5', 'woocommerce' ),
$rating,
);
ob_start();
?>
<div class="wc-block-components-product-rating">
<div class="wc-block-components-product-rating__stars" role="img" aria-label="<?php echo esc_attr( $rating_label ); ?>">
<span style="width: <?php echo esc_attr( $width ); ?>%" aria-hidden="true">
</span>
</div>
<span class="wc-block-components-product-rating-count">
<?php echo esc_html( $count_label ); ?>
</span>
</div>
<?php
return ob_get_clean();
}
/**
* Get the checkbox list items.
*
* @param array $rating_counts The rating counts.
* @param string $selected_ratings_query The url query param for selected ratings.
* @param bool $show_counts Whether to show the counts.
* @return array
*/
private function get_checkbox_list_items( $rating_counts, $selected_ratings_query, $show_counts ) {
$ratings_array = explode( ',', $selected_ratings_query );
return array_map(
function( $rating ) use ( $ratings_array, $show_counts ) {
$rating_str = (string) $rating['rating'];
$count = $rating['count'];
$count_label = $show_counts ? "($count)" : '';
return array(
'id' => 'rating-' . $rating_str,
'checked' => in_array( $rating_str, $ratings_array, true ),
'label' => $this->render_rating_label( (int) $rating_str, $count_label ),
'value' => $rating_str,
);
},
$rating_counts
);
}
/**
* Get the dropdown props.
*
* @param mixed $rating_counts The rating counts.
* @param mixed $selected_ratings_query The url query param for selected ratings.
* @param mixed $show_counts Whether to show the counts.
* @return array<array-key, array>
*/
private function get_dropdown_props( $rating_counts, $selected_ratings_query, $show_counts ) {
$ratings_array = explode( ',', $selected_ratings_query );
$selected_item = array_reduce(
$rating_counts,
function( $carry, $rating ) use ( $ratings_array, $show_counts ) {
if ( in_array( (string) $rating['rating'], $ratings_array, true ) ) {
$count = $rating['count'];
$count_label = $show_counts ? "($count)" : '';
$rating_str = (string) $rating['rating'];
return array(
/* translators: %d is referring to the average rating value. Example: Rated 4 out of 5. */
'label' => sprintf( __( 'Rated %d out of 5', 'woocommerce' ), $rating_str ) . ' ' . $count_label,
'value' => $rating['rating'],
);
}
return $carry;
},
array()
);
return array(
'items' => array_map(
function ( $rating ) use ( $show_counts ) {
$count = $rating['count'];
$count_label = $show_counts ? "($count)" : '';
$rating_str = (string) $rating['rating'];
return array(
/* translators: %d is referring to the average rating value. Example: Rated 4 out of 5. */
'label' => sprintf( __( 'Rated %d out of 5', 'woocommerce' ), $rating_str ) . ' ' . $count_label,
'value' => $rating['rating'],
);
},
$rating_counts
),
'selected_item' => $selected_item,
'action' => 'woocommerce/collection-rating-filter::actions.onDropdownChange',
);
}
}

View File

@ -127,7 +127,7 @@ final class BlockTypesController {
*
* @param array $allowed_namespaces List of namespaces.
*/
$allowed_namespaces = array_merge( [ 'woocommerce', 'woocommerce-checkout' ], (array) apply_filters( '__experimental_woocommerce_blocks_add_data_attributes_to_namespace', [] ) );
$allowed_namespaces = array_merge( array( 'woocommerce', 'woocommerce-checkout' ), (array) apply_filters( '__experimental_woocommerce_blocks_add_data_attributes_to_namespace', array() ) );
/**
* Filters the list of allowed Block Names
@ -138,17 +138,17 @@ final class BlockTypesController {
*
* @param array $allowed_namespaces List of namespaces.
*/
$allowed_blocks = (array) apply_filters( '__experimental_woocommerce_blocks_add_data_attributes_to_block', [] );
$allowed_blocks = (array) apply_filters( '__experimental_woocommerce_blocks_add_data_attributes_to_block', array() );
if ( ! in_array( $block_namespace, $allowed_namespaces, true ) && ! in_array( $block_name, $allowed_blocks, true ) ) {
return $content;
}
$attributes = (array) $block['attrs'];
$exclude_attributes = [ 'className', 'align' ];
$escaped_data_attributes = [
$exclude_attributes = array( 'className', 'align' );
$escaped_data_attributes = array(
'data-block-name="' . esc_attr( $block['blockName'] ) . '"',
];
);
foreach ( $attributes as $key => $value ) {
if ( in_array( $key, $exclude_attributes, true ) ) {
@ -215,7 +215,7 @@ final class BlockTypesController {
protected function get_block_types() {
global $pagenow;
$block_types = [
$block_types = array(
'ActiveFilters',
'AddToCartForm',
'AllProducts',
@ -281,7 +281,7 @@ final class BlockTypesController {
'OrderConfirmation\BillingWrapper',
'OrderConfirmation\ShippingWrapper',
'OrderConfirmation\AdditionalInformation',
];
);
$block_types = array_merge(
$block_types,
@ -300,29 +300,30 @@ final class BlockTypesController {
$block_types[] = 'CollectionStockFilter';
$block_types[] = 'CollectionPriceFilter';
$block_types[] = 'CollectionAttributeFilter';
$block_types[] = 'CollectionRatingFilter';
}
/**
* This disables specific blocks in Widget Areas by not registering them.
*/
if ( in_array( $pagenow, [ 'widgets.php', 'themes.php', 'customize.php' ], true ) && ( empty( $_GET['page'] ) || 'gutenberg-edit-site' !== $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
if ( in_array( $pagenow, array( 'widgets.php', 'themes.php', 'customize.php' ), true ) && ( empty( $_GET['page'] ) || 'gutenberg-edit-site' !== $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
$block_types = array_diff(
$block_types,
[
array(
'AllProducts',
'Cart',
'Checkout',
]
)
);
}
/**
* This disables specific blocks in Post and Page editor by not registering them.
*/
if ( in_array( $pagenow, [ 'post.php', 'post-new.php' ], true ) ) {
if ( in_array( $pagenow, array( 'post.php', 'post-new.php' ), true ) ) {
$block_types = array_diff(
$block_types,
[
array(
'AddToCartForm',
'Breadcrumbs',
'CatalogSorting',
@ -340,7 +341,7 @@ final class BlockTypesController {
'OrderConfirmation\BillingWrapper',
'OrderConfirmation\ShippingWrapper',
'OrderConfirmation\AdditionalInformation',
]
)
);
}

View File

@ -0,0 +1,76 @@
<?php
namespace Automattic\WooCommerce\Blocks\InteractivityComponents;
/**
* CheckboxList class. This is a component for reuse with interactivity API.
*
* @package Automattic\WooCommerce\Blocks\InteractivityComponents
*/
class CheckboxList {
/**
* Render the checkbox list.
*
* @param mixed $props The properties to render the dropdown with.
* items: array of objects with label and value properties.
* - id: string of the id to use for the checkbox (optional).
* - checked: boolean to indicate if the checkbox is checked.
* - label: string of the label to display (plaintext or HTML).
* - value: string of the value to use.
* on_change: string of the action to perform when the dropdown changes.
* @return string|false
*/
public static function render( $props ) {
wp_enqueue_script( 'wc-interactivity-checkbox-list' );
wp_enqueue_style( 'wc-interactivity-checkbox-list' );
$items = $props['items'] ?? array();
$checkbox_list_context = array( 'items' => $items );
// Items should be an array of objects with a label (which can be plaintext or HTML) and value property.
$items = $props['items'] ?? array();
$namespace = wp_json_encode( array( 'namespace' => 'woocommerce/interactivity-checkbox-list' ) );
ob_start();
?>
<div data-wc-interactive='<?php echo esc_attr( $namespace ); ?>'>
<div data-wc-context='<?php echo esc_attr( wp_json_encode( $checkbox_list_context ) ); ?>' >
<div class="wc-block-stock-filter style-list">
<ul class="wc-block-checkbox-list wc-block-components-checkbox-list wc-block-stock-filter-list">
<?php foreach ( $items as $item ) { ?>
<?php $item['id'] = $item['id'] ?? uniqid( 'checkbox-' ); ?>
<li>
<div class="wc-block-components-checkbox wc-block-checkbox-list__checkbox">
<label for="<?php echo esc_attr( $item['id'] ); ?>">
<input
id="<?php echo esc_attr( $item['id'] ); ?>"
class="wc-block-components-checkbox__input"
type="checkbox"
aria-invalid="false"
data-wc-on--change--select-item="actions.selectCheckboxItem"
data-wc-on--change--parent-action="<?php echo esc_attr( $props['on_change'] ?? '' ); ?>"
value="<?php echo esc_attr( $item['value'] ); ?>"
<?php checked( $item['checked'], 1 ); ?>
>
<svg class="wc-block-components-checkbox__mark" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 20">
<path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"></path>
</svg>
<span class="wc-block-components-checkbox__label">
<?php // The label can be HTML, so we don't want to escape it. ?>
<?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php echo $item['label']; ?>
</span>
</label>
</div>
</li>
<?php } ?>
</ul>
</div>
</div>
</div>
<?php
return ob_get_clean();
}
}

View File

@ -16,6 +16,7 @@ class Dropdown {
*/
public static function render( $props ) {
wp_enqueue_script( 'wc-interactivity-dropdown' );
wp_enqueue_style( 'wc-interactivity-dropdown' );
$selected_item = $props['selected_item'] ?? array(
'label' => null,
@ -31,15 +32,19 @@ class Dropdown {
'isOpen' => false,
);
wc_initial_state( 'woocommerce/interactivity-dropdown', array( 'selectedItem' => $selected_item ) );
$action = $props['action'] ?? '';
// Items should be an array of objects with a label and value property.
$items = $props['items'] ?? [];
$items = $props['items'] ?? array();
$namespace = wp_json_encode( array( 'namespace' => 'woocommerce/interactivity-dropdown' ) );
ob_start();
?>
<div data-wc-interactive='<?php echo wp_json_encode( array( 'namespace' => 'woocommerce/interactivity-dropdown' ) ); ?>'>
<div class="wc-block-stock-filter style-dropdown" data-wc-context='<?php echo wp_json_encode( $dropdown_context ); ?>' >
<div data-wc-interactive='<?php echo esc_attr( $namespace ); ?>'>
<div class="wc-interactivity-dropdown" data-wc-context='<?php echo esc_attr( wp_json_encode( $dropdown_context ) ); ?>' >
<div class="wc-blocks-components-form-token-field-wrapper single-selection" >
<div class="components-form-token-field" tabindex="-1">
<div class="components-form-token-field__input-container"
@ -59,14 +64,14 @@ class Dropdown {
role="option"
data-wc-on--click--select-item="actions.selectDropdownItem"
data-wc-on--click--parent-action="<?php echo esc_attr( $action ); ?>"
data-wc-class--is-selected="state.isSelected"
data-wc-on--mouseover="actions.addHoverClass"
data-wc-on--mouseout="actions.removeHoverClass"
data-wc-context='<?php echo wp_json_encode( $context ); ?>'
data-wc-class--is-selected="state.isSelected"
data-wc-context='<?php echo esc_attr( wp_json_encode( $context ) ); ?>'
class="components-form-token-field__suggestion"
data-wc-bind--aria-selected="state.isSelected"
>
<?php echo esc_html( $item['label'] ); ?>
<?php // This attribute supports HTML so should be sanitized by caller. ?>
<?php // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<?php echo $item['label']; ?>
</li>
<?php endforeach; ?>
</ul>