Add a stock filter block powered by the interactivity API (https://github.com/woocommerce/woocommerce-blocks/pull/11663)

This commit is contained in:
Sam Seay 2023-11-15 11:50:05 +08:00 committed by GitHub
parent 49579f4aa0
commit d62b950aae
18 changed files with 1018 additions and 28 deletions

View File

@ -0,0 +1,46 @@
{
"name": "woocommerce/collection-stock-filter",
"version": "1.0.0",
"title": "Stock Filter",
"description": "Enable customers to filter the product collection by stock status.",
"category": "woocommerce",
"keywords": [ "WooCommerce", "filter", "stock" ],
"supports": {
"interactivity": true,
"html": false,
"multiple": false
},
"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_stock_status_counts": "true"
}
}
},
"usesContext": [ "collectionData" ],
"ancestor": [ "woocommerce/collection-filters" ],
"textdomain": "woo-gutenberg-products-block",
"apiVersion": 2,
"$schema": "https://schemas.wp.org/trunk/block.json"
}

View File

@ -0,0 +1,96 @@
/**
* External dependencies
*/
import { InspectorControls } from '@wordpress/block-editor';
import { __ } from '@wordpress/i18n';
import {
PanelBody,
ToggleControl,
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControl as ToggleGroupControl,
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
} from '@wordpress/components';
/**
* Internal dependencies
*/
import { EditProps } from '../types';
export const Inspector = ( { attributes, setAttributes }: EditProps ) => {
const { showCounts, selectType, displayStyle } = attributes;
return (
<InspectorControls key="inspector">
<PanelBody
title={ __(
'Display Settings',
'woo-gutenberg-products-block'
) }
>
<ToggleControl
label={ __(
'Display product count',
'woo-gutenberg-products-block'
) }
checked={ showCounts }
onChange={ () =>
setAttributes( {
showCounts: ! showCounts,
} )
}
/>
<ToggleGroupControl
label={ __(
'Allow selecting multiple options?',
'woo-gutenberg-products-block'
) }
value={ selectType || 'multiple' }
onChange={ ( value: string ) =>
setAttributes( {
selectType: value,
} )
}
className="wc-block-attribute-filter__multiple-toggle"
>
<ToggleGroupControlOption
value="multiple"
label={ __(
'Multiple',
'woo-gutenberg-products-block'
) }
/>
<ToggleGroupControlOption
value="single"
label={ __( 'Single', 'woo-gutenberg-products-block' ) }
/>
</ToggleGroupControl>
<ToggleGroupControl
label={ __(
'Display Style',
'woo-gutenberg-products-block'
) }
value={ displayStyle }
onChange={ ( value ) =>
setAttributes( {
displayStyle: value,
} )
}
className="wc-block-attribute-filter__display-toggle"
>
<ToggleGroupControlOption
value="list"
label={ __( 'List', 'woo-gutenberg-products-block' ) }
/>
<ToggleGroupControlOption
value="dropdown"
label={ __(
'Dropdown',
'woo-gutenberg-products-block'
) }
/>
</ToggleGroupControl>
</PanelBody>
</InspectorControls>
);
};

View File

@ -0,0 +1,131 @@
/**
* External dependencies
*/
import { useMemo } from '@wordpress/element';
import classnames from 'classnames';
import { useBlockProps } from '@wordpress/block-editor';
import { Disabled } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { Icon, chevronDown } from '@wordpress/icons';
import { ProductQueryContext as Context } from '@woocommerce/blocks/product-query/types';
import { CheckboxList } from '@woocommerce/blocks-components';
import Label from '@woocommerce/base-components/filter-element-label';
import FormTokenField from '@woocommerce/base-components/form-token-field';
import type { BlockEditProps } from '@wordpress/blocks';
import { getSetting } from '@woocommerce/settings';
import {
useCollectionData,
useQueryStateByContext,
} from '@woocommerce/base-context/hooks';
/**
* Internal dependencies
*/
import { BlockProps } from './types';
import { Inspector } from './components/inspector';
type CollectionData = {
// attribute_counts: null | unknown;
// price_range: null | unknown;
// rating_counts: null | unknown;
stock_status_counts: StockStatusCount[];
};
type StockStatusCount = {
status: string;
count: number;
};
const Edit = ( props: BlockEditProps< BlockProps > & { context: Context } ) => {
const blockProps = useBlockProps( {
className: classnames(
'wc-block-stock-filter',
props.attributes.className
),
} );
const { showCounts, displayStyle } = props.attributes;
const stockStatusOptions: Record< string, string > = getSetting(
'stockStatusOptions',
{}
);
const [ queryState ] = useQueryStateByContext();
const { results: filteredCounts } = useCollectionData( {
queryStock: true,
queryState,
isEditor: true,
} );
const listOptions = useMemo( () => {
return Object.entries( stockStatusOptions ).map( ( [ key, value ] ) => {
const count =
// @ts-expect-error - there is a fault with useCollectionData types, it can be non-array.
( filteredCounts as CollectionData )?.stock_status_counts?.find(
( item: StockStatusCount ) => item.status === key
)?.count;
return {
value: key,
label: (
<Label
name={ value }
count={ showCounts && count ? Number( count ) : null }
/>
),
};
} );
}, [ stockStatusOptions, filteredCounts, showCounts ] );
return (
<>
{
<div { ...blockProps }>
<Inspector { ...props } />
<Disabled>
<div
className={ classnames(
'wc-block-stock-filter',
`style-${ displayStyle }`,
{
'is-loading': false,
}
) }
>
{ displayStyle === 'dropdown' ? (
<>
<FormTokenField
className={ classnames( {
'single-selection': true,
'is-loading': false,
} ) }
suggestions={ [] }
placeholder={ __(
'Select stock status',
'woo-gutenberg-products-block'
) }
onChange={ () => null }
value={ [] }
/>
<Icon icon={ chevronDown } size={ 30 } />
</>
) : (
<CheckboxList
className={ 'wc-block-stock-filter-list' }
options={ listOptions }
checked={ [] }
onChange={ () => null }
isLoading={ false }
isDisabled={ true }
/>
) }
</div>
</Disabled>
</div>
}
</>
);
};
export default Edit;

View File

@ -0,0 +1,79 @@
/**
* External dependencies
*/
import {
store as interactivityStore,
navigate,
} from '@woocommerce/interactivity';
import { DropdownContext } from '@woocommerce/interactivity-components/dropdown';
import { HTMLElementEvent } from '@woocommerce/types';
const getUrl = ( activeFilters: string ) => {
const url = new URL( window.location.href );
const { searchParams } = url;
if ( activeFilters !== '' ) {
searchParams.set( 'filter_stock_status', activeFilters );
} else {
searchParams.delete( 'filter_stock_status' );
}
return url.href;
};
type StockFilterState = {
filters: {
stockStatus: string;
activeFilters: string;
showDropdown: boolean;
};
};
type ActionProps = {
state: StockFilterState;
event: HTMLElementEvent< HTMLInputElement >;
};
interactivityStore( {
state: {
filters: {
stockStatus: '',
},
},
actions: {
filters: {
navigate: ( { context }: { context: DropdownContext } ) => {
if ( context.woocommerceDropdown.selectedItem.value ) {
navigate(
getUrl( context.woocommerceDropdown.selectedItem.value )
);
}
},
updateProducts: ( { event }: ActionProps ) => {
// get the active filters from the url:
const url = new URL( window.location.href );
const currentFilters =
url.searchParams.get( 'filter_stock_status' ) || '';
// split out the active filters into an array.
const filtersArr =
currentFilters === '' ? [] : currentFilters.split( ',' );
// if checked and not already in activeFilters, add to activeFilters
// if not checked and in activeFilters, remove from activeFilters.
if ( event.target.checked ) {
if ( ! currentFilters.includes( event.target.value ) ) {
filtersArr.push( event.target.value );
}
} else {
const index = filtersArr.indexOf( event.target.value );
if ( index > -1 ) {
filtersArr.splice( index, 1 );
}
}
navigate( getUrl( filtersArr.join( ',' ) ) );
},
},
},
} );

View File

@ -0,0 +1,27 @@
/**
* External dependencies
*/
import { registerBlockType } from '@wordpress/blocks';
import { Icon, box } from '@wordpress/icons';
import { isExperimentalBuild } from '@woocommerce/block-settings';
/**
* Internal dependencies
*/
import './style.scss';
import edit from './edit';
import metadata from './block.json';
if ( isExperimentalBuild() ) {
registerBlockType( metadata, {
icon: {
src: (
<Icon
icon={ box }
className="wc-block-editor-components-block-icon"
/>
),
},
edit,
} );
}

View File

@ -0,0 +1,25 @@
/**
* External dependencies
*/
import Label from '@woocommerce/base-components/filter-element-label';
export const previewOptions = [
{
value: 'preview-1',
name: 'In Stock',
label: <Label name="In Stock" count={ 3 } />,
textLabel: 'In Stock (3)',
},
{
value: 'preview-2',
name: 'Out of stock',
label: <Label name="Out of stock" count={ 3 } />,
textLabel: 'Out of stock (3)',
},
{
value: 'preview-3',
name: 'On backorder',
label: <Label name="On backorder" count={ 2 } />,
textLabel: 'On backorder (2)',
},
];

View File

@ -0,0 +1,181 @@
@import "../../../shared/styles/style";
// Import styles we need to render the checkbox list and checkbox control.
@import "../../../../../../packages/components/checkbox-list/style";
@import "../../../../../../packages/checkout/components/checkbox-control/style";
.wp-block-woocommerce-stock-filter {
h1,
h2,
h3,
h4,
h5,
h6 {
text-transform: inherit;
}
}
.wc-block-stock-filter {
&.is-loading {
@include placeholder();
margin-top: $gap;
box-shadow: none;
border-radius: 0;
}
margin-bottom: $gap-large;
.wc-block-stock-filter-list {
margin: 0;
li {
label {
cursor: pointer;
}
input {
cursor: pointer;
display: inline-block;
}
}
}
&.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-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;
}
}
.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,27 @@
/**
* External dependencies
*/
import { BlockEditProps } from '@wordpress/blocks';
export interface BlockProps {
className?: string;
showCounts: boolean;
isPreview?: boolean;
displayStyle: string;
selectType: string;
isEditor: boolean;
}
export interface DisplayOption {
value: string;
name: string;
label: JSX.Element;
textLabel: string;
}
export type Current = {
slug: string;
name: string;
};
export type EditProps = BlockEditProps< BlockProps >;

View File

@ -1,4 +1,10 @@
import { useContext, useMemo, useEffect, useLayoutEffect } from 'preact/hooks';
import {
useContext,
useMemo,
useEffect,
useLayoutEffect,
useRef,
} from 'preact/hooks';
import { deepSignal, peek } from 'deepsignal';
import { useSignalEffect } from './utils';
import { directive } from './hooks';
@ -7,18 +13,16 @@ import { prefetch, navigate } from './router';
const isObject = ( item ) =>
item && typeof item === 'object' && ! Array.isArray( item );
const mergeDeepSignals = ( target, source ) => {
const mergeDeepSignals = ( target, source, overwrite ) => {
for ( const k in source ) {
if ( typeof peek( target, k ) === 'undefined' ) {
target[ `$${ k }` ] = source[ `$${ k }` ];
} else if (
isObject( peek( target, k ) ) &&
isObject( peek( source, k ) )
) {
if ( isObject( peek( target, k ) ) && isObject( peek( source, k ) ) ) {
mergeDeepSignals(
target[ `$${ k }` ].peek(),
source[ `$${ k }` ].peek()
source[ `$${ k }` ].peek(),
overwrite
);
} else if ( overwrite || typeof peek( target, k ) === 'undefined' ) {
target[ `$${ k }` ] = source[ `$${ k }` ];
}
}
};
@ -29,20 +33,24 @@ export default () => {
'context',
( {
directives: {
context: { default: context },
context: { default: newContext },
},
props: { children },
context: inherited,
context: inheritedContext,
} ) => {
const { Provider } = inherited;
const inheritedValue = useContext( inherited );
const value = useMemo( () => {
const localValue = deepSignal( context );
mergeDeepSignals( localValue, inheritedValue );
return localValue;
}, [ context, inheritedValue ] );
const { Provider } = inheritedContext;
const inheritedValue = useContext( inheritedContext );
const currentValue = useRef( deepSignal( {} ) );
currentValue.current = useMemo( () => {
const newValue = deepSignal( newContext );
mergeDeepSignals( newValue, inheritedValue );
mergeDeepSignals( currentValue.current, newValue, true );
return currentValue.current;
}, [ newContext, inheritedValue ] );
return <Provider value={ value }>{ children }</Provider>;
return (
<Provider value={ currentValue.current }>{ children }</Provider>
);
},
{ priority: 5 }
);
@ -87,9 +95,17 @@ export default () => {
// data-wc-on--[event]
directive( 'on', ( { directives: { on }, element, evaluate, context } ) => {
const contextValue = useContext( context );
const events = new Map();
Object.entries( on ).forEach( ( [ name, path ] ) => {
element.props[ `on${ name }` ] = ( event ) => {
evaluate( path, { event, context: contextValue } );
const event = name.split( '--' )[ 0 ];
if ( ! events.has( event ) ) events.set( event, new Set() );
events.get( event ).add( path );
} );
events.forEach( ( paths, event ) => {
element.props[ `on${ event }` ] = ( event ) => {
paths.forEach( ( path ) => {
evaluate( path, { event, context: contextValue } );
} );
};
} );
} );

View File

@ -96,6 +96,10 @@ const blocks = {
'collection-filters': {
isExperimental: true,
},
'collection-stock-filter': {
isExperimental: true,
customDir: 'collection-filters/inner-blocks/stock-filter',
},
'collection-price-filter': {
customDir: 'collection-filters/inner-blocks/price-filter',
isExperimental: true,
@ -196,6 +200,10 @@ const entries = {
priceFormat: './packages/prices/index.js',
blocksCheckout: './packages/checkout/index.js',
blocksComponents: './packages/components/index.ts',
// interactivity components, exported as separate entries for now
'wc-interactivity-dropdown':
'./packages/interactivity-components/dropdown/index.ts',
},
main: {
// Shared blocks code

View File

@ -0,0 +1,95 @@
/**
* External dependencies
*/
import { store as interactivityStore } from '@woocommerce/interactivity';
export type DropdownContext = {
woocommerceDropdown: {
currentItem: {
label: string;
value: string;
};
selectedItem: {
label: string | null;
value: string | null;
};
hoveredItem: {
label: string | null;
value: string | null;
};
isOpen: boolean;
};
};
type Store = {
context: DropdownContext;
selectors: unknown;
ref: HTMLElement;
};
interactivityStore( {
state: {},
selectors: {
woocommerceDropdown: {
placeholderText: ( { context }: { context: DropdownContext } ) => {
const {
woocommerceDropdown: { selectedItem },
} = context;
return selectedItem.label || 'Select an option';
},
isSelected: ( { context }: { context: DropdownContext } ) => {
const {
woocommerceDropdown: {
currentItem: { value },
},
} = context;
return (
context.woocommerceDropdown.selectedItem.value === value ||
context.woocommerceDropdown.hoveredItem.value === value
);
},
},
},
actions: {
woocommerceDropdown: {
toggleIsOpen: ( store: Store ) => {
const {
context: { woocommerceDropdown },
} = store;
woocommerceDropdown.isOpen = ! woocommerceDropdown.isOpen;
},
selectDropdownItem: ( {
context,
}: {
context: DropdownContext;
} ) => {
const {
woocommerceDropdown: {
currentItem: { label, value },
},
} = context;
context.woocommerceDropdown.selectedItem = { label, value };
context.woocommerceDropdown.isOpen = false;
},
addHoverClass: ( { context }: { context: DropdownContext } ) => {
const {
woocommerceDropdown: {
currentItem: { label, value },
},
} = context;
context.woocommerceDropdown.hoveredItem = { label, value };
},
removeHoverClass: ( { context }: { context: DropdownContext } ) => {
context.woocommerceDropdown.hoveredItem = {
label: null,
value: null,
};
},
},
},
} );

View File

@ -64,6 +64,9 @@ final class AssetsController {
$this->api->register_script( 'wc-blocks-checkout', 'build/blocks-checkout.js', [] );
$this->api->register_script( 'wc-blocks-components', 'build/blocks-components.js', [] );
// Register the interactivity components here for now.
$this->api->register_script( 'wc-interactivity-dropdown', 'build/wc-interactivity-dropdown.js', [] );
wp_add_inline_script(
'wc-blocks-middleware',
"

View File

@ -125,13 +125,7 @@ final class CollectionFilters extends AbstractBlock {
);
if ( ! empty( $response['body'] ) ) {
$normalized_response = array();
foreach ( $response['body'] as $key => $data ) {
$normalized_response[ $key ] = (array) $data;
}
return $normalized_response;
return json_decode( wp_json_encode( $response['body'] ), true );
}
return array();

View File

@ -0,0 +1,172 @@
<?php
namespace Automattic\WooCommerce\Blocks\BlockTypes;
use Automattic\WooCommerce\Blocks\InteractivityComponents\Dropdown;
/**
* CollectionStockFilter class.
*/
final class CollectionStockFilter extends AbstractBlock {
/**
* Block name.
*
* @var string
*/
protected $block_name = 'collection-stock-filter';
const STOCK_STATUS_QUERY_VAR = 'filter_stock_status';
/**
* Extra data passed through from server to client for block.
*
* @param array $stock_statuses Any stock statuses that currently are available from the block.
* Note, this will be empty in the editor context when the block is
* not in the post content on editor load.
*/
protected function enqueue_data( array $stock_statuses = [] ) {
parent::enqueue_data( $stock_statuses );
$this->asset_data_registry->add( 'stockStatusOptions', wc_get_product_stock_status_options(), true );
$this->asset_data_registry->add( 'hideOutOfStockItems', 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ), true );
}
/**
* 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 '';
}
$stock_status_counts = $block->context['collectionData']['stock_status_counts'] ?? [];
$wrapper_attributes = get_block_wrapper_attributes();
wc_store(
array(
'state' => array(
'filters' => array(
'stockStatus' => $stock_status_counts,
'activeFilters' => '',
),
),
)
);
return sprintf(
'<div %1$s>
<div class="wc-block-stock-filter__controls">%2$s</div>
<div class="wc-block-stock-filter__actions"></div>
</div>',
$wrapper_attributes,
$this->get_stock_filter_html( $stock_status_counts, $attributes ),
);
}
/**
* Stock filter HTML
*
* @param array $stock_counts An array of stock counts.
* @param array $attributes Block attributes. Default empty array.
* @return string Rendered block type output.
*/
private function get_stock_filter_html( $stock_counts, $attributes ) {
$display_style = $attributes['displayStyle'] ?? 'list';
$stock_statuses = wc_get_product_stock_status_options();
// check the url params to select initial item on page load.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verification is not required here.
$selected_stock_status = isset( $_GET[ self::STOCK_STATUS_QUERY_VAR ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::STOCK_STATUS_QUERY_VAR ] ) ) : '';
$list_items = array_map(
function( $item ) use ( $stock_statuses ) {
return array(
'label' => $stock_statuses[ $item['status'] ],
'value' => $item['status'],
);
},
$stock_counts
);
$selected_items = array_values(
array_filter(
$list_items,
function( $item ) use ( $selected_stock_status ) {
return $item['value'] === $selected_stock_status;
}
)
);
// Just for the dropdown, we can only select 1 item.
$selected_item = $selected_items[0] ?? array(
'label' => null,
'value' => null,
);
ob_start();
?>
<?php if ( 'list' === $display_style ) : ?>
<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 ( $stock_counts as $stock_count ) { ?>
<li>
<div class="wc-block-components-checkbox wc-block-checkbox-list__checkbox">
<label for="<?php echo esc_attr( $stock_count['status'] ); ?>">
<input
id="<?php echo esc_attr( $stock_count['status'] ); ?>"
class="wc-block-components-checkbox__input"
type="checkbox"
aria-invalid="false"
data-wc-on--change="actions.filters.updateProducts"
value="<?php echo esc_attr( $stock_count['status'] ); ?>"
<?php checked( strpos( $selected_stock_status, $stock_count['status'] ) !== false, 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 echo esc_html( $stock_statuses[ $stock_count['status'] ] ); ?>
<?php
// translators: %s: number of products.
$screen_reader_text = sprintf( _n( '%s product', '%s products', $stock_count['count'], 'woo-gutenberg-products-block' ), number_format_i18n( $stock_count['count'] ) );
?>
<span class="wc-filter-element-label-list-count">
<span aria-hidden="true">
<?php echo esc_html( $stock_count['count'] ); ?>
</span>
<span class="screen-reader-text">
<?php esc_html( $screen_reader_text ); ?>
</span>
</span>
</span>
</label>
</div>
</li>
<?php } ?>
</ul>
</div>
<?php endif; ?>
<?php if ( 'dropdown' === $display_style ) : ?>
<?php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Dropdown::render() escapes output.
echo Dropdown::render(
array(
'items' => $list_items,
'action' => 'actions.filters.navigate',
'selected_item' => $selected_item,
)
);
?>
<?php endif; ?>
<?php
return ob_get_clean();
}
}

View File

@ -296,6 +296,7 @@ final class BlockTypesController {
$block_types[] = 'ProductGalleryPager';
$block_types[] = 'ProductGalleryThumbnails';
$block_types[] = 'CollectionFilters';
$block_types[] = 'CollectionStockFilter';
$block_types[] = 'CollectionPriceFilter';
}

View File

@ -0,0 +1,85 @@
<?php
namespace Automattic\WooCommerce\Blocks\InteractivityComponents;
/**
* Dropdown class. This is a component for reuse with interactivity API.
*
* @package Automattic\WooCommerce\Blocks\InteractivityComponents
*/
class Dropdown {
/**
* Render the dropdown.
*
* @param mixed $props The properties to render the dropdown with.
* @return string|false
*/
public static function render( $props ) {
wp_enqueue_script( 'wc-interactivity-dropdown' );
$selected_item = $props['selected_item'] ?? array(
'label' => null,
'value' => null,
);
$dropdown_context = array(
'woocommerceDropdown' => array(
'selectedItem' => $selected_item,
'hoveredItem' => array(
'label' => null,
'value' => null,
),
'isOpen' => false,
),
);
$action = $props['action'] ?? '';
// Items should be an array of objects with a label and value property.
$items = $props['items'] ?? [];
ob_start();
?>
<div class="wc-block-stock-filter style-dropdown" data-wc-context='<?php echo 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"
data-wc-class--is-active="context.woocommerceDropdown.isOpen"
tabindex="-1"
data-wc-on--click="actions.woocommerceDropdown.toggleIsOpen"
>
<input id="components-form-token-input-1" type="text" autocomplete="off" data-wc-bind--placeholder="selectors.woocommerceDropdown.placeholderText" class="components-form-token-field__input" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-describedby="components-form-token-suggestions-howto-1" value="">
<ul hidden data-wc-bind--hidden="!context.woocommerceDropdown.isOpen" class="components-form-token-field__suggestions-list" id="components-form-token-suggestions-1" role="listbox">
<?php
foreach ( $items as $item ) :
$context = array(
'woocommerceDropdown' => array( 'currentItem' => $item ),
JSON_NUMERIC_CHECK,
);
?>
<li
role="option"
data-wc-on--click--select-item="actions.woocommerceDropdown.selectDropdownItem"
data-wc-on--click--parent-action="<?php echo esc_attr( $action ); ?>"
data-wc-class--is-selected="selectors.woocommerceDropdown.isSelected"
data-wc-on--mouseover="actions.woocommerceDropdown.addHoverClass"
data-wc-on--mouseout="actions.woocommerceDropdown.removeHoverClass"
data-wc-context='<?php echo wp_json_encode( $context ); ?>'
class="components-form-token-field__suggestion"
data-wc-bind--aria-selected="selectors.woocommerceDropdown.isSelected"
>
<?php echo esc_html( $item['label'] ); ?>
</li>
<?php endforeach; ?>
</ul>
</div>
</div>
</div>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="30" height="30" >
<path d="M17.5 11.6L12 16l-5.5-4.4.9-1.2L12 14l4.5-3.6 1 1.2z" ></path>
</svg>
</div>
<?php
return ob_get_clean();
}
}

View File

@ -52,6 +52,9 @@
"@woocommerce/blocks-registry": [ "assets/js/blocks-registry" ],
"@woocommerce/blocks-checkout": [ "packages/checkout" ],
"@woocommerce/blocks-components": [ "packages/components" ],
"@woocommerce/interactivity-components/*": [
"packages/interactivity-components/*"
],
"@woocommerce/price-format": [ "packages/prices" ],
"@woocommerce/block-settings": [ "assets/js/settings/blocks" ],
"@woocommerce/icons": [ "assets/js/icons" ],

View File

@ -4,6 +4,7 @@
"./assets/js/**/*",
"./packages/checkout/**/*",
"./packages/components/**/*",
"./packages/interactivity-components/**/*",
"./assets/js/blocks/**/block.json",
"./assets/js/atomic/blocks/**/block.json",
"./assets/js/blocks/mini-cart/mini-cart-contents/inner-blocks/**/block.json",