Products whose variations are on the page already. */
private $printed = array();
public function register(): void {
add_action( 'wp_enqueue_scripts', array( $this, 'assets' ) );
add_filter( 'woocommerce_dropdown_variation_attribute_options_html', array( $this, 'dropdown' ), 20, 2 );
add_action( 'woocommerce_before_variations_form', array( $this, 'print_data' ) );
add_filter( 'render_block_woocommerce/product-filter-chips', array( $this, 'block_chips' ), 10, 3 );
add_filter( 'render_block_woocommerce/dropdown', array( $this, 'block_dropdown' ), 10, 3 );
add_filter( 'render_block_woocommerce/add-to-cart-with-options-variation-selector-attribute-name', array( $this, 'block_name' ), 10, 3 );
}
public function assets(): void {
self::register_assets();
if ( is_product() ) {
$product = wc_get_product( get_queried_object_id() );
if ( $product instanceof WC_Product && self::has_swatches( $product ) ) {
self::enqueue();
}
}
}
public static function register_assets(): void {
if ( wp_style_is( 'swatchline', 'registered' ) ) {
return;
}
wp_register_style( 'swatchline', SWATCHLINE_URL . 'assets/css/frontend.css', array(), SWATCHLINE_VERSION );
wp_add_inline_style( 'swatchline', '.swatchline-swatches,table.variations tr{--swatchline-size:' . Settings::size() . 'px}' );
wp_register_script( 'swatchline', SWATCHLINE_URL . 'assets/js/frontend.js', array(), SWATCHLINE_VERSION, true );
wp_add_inline_script( 'swatchline', 'window.swatchlineConfig = ' . wp_json_encode( self::config() ) . ';', 'before' );
}
/** The script's settings and strings. */
public static function config(): array {
/**
* The product page script's settings (`swatchlineConfig`).
*
* @param array $config
*/
return (array) apply_filters(
'swatchline_frontend_config',
array(
'showSelected' => Settings::show_selected(),
'soldOut' => 'cross',
'i18n' => array(
/* translators: shown after a swatch's name when it is out of stock */
'outOfStock' => __( 'Out of stock', 'swatchline' ),
/* translators: shown after a swatch's name when no variation has it with the other choices */
'unavailable' => __( 'Not available with your other choices', 'swatchline' ),
),
)
);
}
public static function enqueue(): void {
self::register_assets();
wp_enqueue_style( 'swatchline' );
wp_enqueue_script( 'swatchline' );
/** Fires when the swatch styles and script are enqueued, for add-ons' assets. */
do_action( 'swatchline_enqueue_scripts' );
}
/** Whether any attribute of a variable product shows swatches. */
public static function has_swatches( WC_Product $product ): bool {
if ( ! $product->is_type( 'variable' ) ) {
return false;
}
foreach ( $product->get_variation_attributes() as $attribute => $options ) {
if ( null !== Swatches::for_attribute( $product, (string) $attribute, (array) $options ) ) {
return true;
}
}
return false;
}
/**
* Adds swatches after WooCommerce's dropdown of an attribute and hides the dropdown.
*
* @param string $html
* @param array $args wc_dropdown_variation_attribute_options() arguments.
*/
public function dropdown( $html, $args ): string {
$html = (string) $html;
$args = (array) $args;
$product = $args['product'] ?? null;
if ( ! $product instanceof WC_Product ) {
$product = $GLOBALS['product'] ?? null;
}
if ( ! $product instanceof WC_Product || empty( $args['attribute'] ) || ! $product->is_type( 'variable' ) ) {
return $html;
}
$attribute = (string) $args['attribute'];
$options = isset( $args['options'] ) && is_array( $args['options'] ) && $args['options'] ? $args['options'] : ( $product->get_variation_attributes()[ $attribute ] ?? array() );
$swatches = Swatches::for_attribute( $product, $attribute, (array) $options );
if ( null === $swatches ) {
return $html;
}
self::enqueue();
$selected = is_scalar( $args['selected'] ?? '' ) ? (string) ( $args['selected'] ?? '' ) : '';
return $this->data( $product ) . self::hide( $html, 'select' ) . Renderer::html(
$swatches,
$selected,
array( 'classes' => array( 'swatchline-product-' . $product->get_id() ) )
);
}
/** Prints a product's variations in the variation form (classic form and the Add to Cart + Options block). */
public function print_data(): void {
$product = $GLOBALS['product'] ?? null;
if ( $product instanceof WC_Product && self::has_swatches( $product ) ) {
echo $this->data( $product ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in data_html().
}
}
/** The variations as JSON, once per product per page ('' when they're there already). */
private function data( WC_Product $product ): string {
if ( isset( $this->printed[ $product->get_id() ] ) ) {
return '';
}
$this->printed[ $product->get_id() ] = true;
return self::data_html( $product );
}
public static function data_html( WC_Product $product ): string {
$fields = array();
foreach ( array_keys( $product->get_variation_attributes() ) as $attribute ) {
$fields[] = Swatches::field_name( (string) $attribute );
}
$data = array(
'product' => $product->get_id(),
'attributes' => $fields,
'variations' => Swatches::variations( $product ),
);
// Hex-escaped JSON has no & < > " ', so esc_attr() leaves it as it is.
return '';
}
/** Adds the class that hides WooCommerce's control to the first tag of $html. */
private static function hide( string $html, string $tag ): string {
$tags = new WP_HTML_Tag_Processor( $html );
if ( $tags->next_tag( $tag ) ) {
$tags->add_class( 'swatchline-hidden-control' );
return $tags->get_updated_html();
}
return $html;
}
/**
* The attribute of the current product a block's selectable items belong to: WooCommerce gives
* each item the id "attribute_{name}-{value}".
*
* @param array $items
* @return array{0: string, 1: string[]}|null The attribute and its values.
*/
public static function attribute_of_items( WC_Product $product, array $items ): ?array {
$values = array();
$ids = array();
foreach ( $items as $item ) {
if ( ! is_array( $item ) || ! isset( $item['id'], $item['value'] ) ) {
return null;
}
$values[] = (string) $item['value'];
$ids[] = (string) $item['id'];
}
if ( ! $values ) {
return null;
}
foreach ( array_keys( $product->get_variation_attributes() ) as $attribute ) {
$prefix = sanitize_title( Swatches::field_name( (string) $attribute ) );
$match = true;
foreach ( $values as $n => $value ) {
if ( $ids[ $n ] !== $prefix . '-' . sanitize_title( $value ) ) {
$match = false;
break;
}
}
if ( $match ) {
return array( (string) $attribute, $values );
}
}
return null;
}
/** The swatches for an Add to Cart + Options block's dropdown or chips, or null. */
private static function block_swatches( WP_Block $block ): ?array {
$context = $block->context['woocommerce/selectableItems'] ?? null;
$product = $GLOBALS['product'] ?? null;
if ( ! is_array( $context ) || 'woocommerce/add-to-cart-with-options' !== ( $context['storeNamespace'] ?? '' ) || ! $product instanceof WC_Product || ! $product->is_type( 'variable' ) ) {
return null;
}
$found = self::attribute_of_items( $product, is_array( $context['items'] ?? null ) ? $context['items'] : array() );
if ( null === $found ) {
return null;
}
return Swatches::for_attribute( $product, $found[0], $found[1] );
}
/**
* An attribute with swatches, shown as chips: rendered as WooCommerce's Dropdown block instead,
* which gets the swatches (block_dropdown()).
*
* @param string $content
* @param array $parsed
* @param WP_Block $block
*/
public function block_chips( $content, $parsed, $block ): string {
if ( ! $block instanceof WP_Block || null === self::block_swatches( $block ) || ! WP_Block_Type_Registry::get_instance()->is_registered( 'woocommerce/dropdown' ) ) {
return (string) $content;
}
$dropdown = new WP_Block(
array(
'blockName' => 'woocommerce/dropdown',
'attrs' => array(),
'innerBlocks' => array(),
'innerHTML' => '',
'innerContent' => array(),
),
$block->context
);
return (string) $dropdown->render();
}
/**
* Adds swatches after the Add to Cart + Options block's dropdown of an attribute and hides it.
*
* @param string $content
* @param array $parsed
* @param WP_Block $block
*/
public function block_dropdown( $content, $parsed, $block ): string {
$content = (string) $content;
$swatches = $block instanceof WP_Block ? self::block_swatches( $block ) : null;
if ( null === $swatches || '' === $content ) {
return $content;
}
$product = $GLOBALS['product'];
self::enqueue();
// The block hydrates this markup, and a `checked` attribute would be put back on every render:
// the script checks the swatch of the dropdown's chosen value instead.
return $this->data( $product ) . self::hide( $content, 'div' ) . Renderer::html(
$swatches,
'',
array( 'classes' => array( 'swatchline-product-' . $product->get_id(), 'swatchline-swatches--block' ) )
);
}
/**
* The chosen value after an attribute's name in the Add to Cart + Options block ("Colour: Sage").
*
* @param string $content
* @param array $parsed
* @param WP_Block $block
*/
public function block_name( $content, $parsed, $block ): string {
$content = (string) $content;
$product = $GLOBALS['product'] ?? null;
if ( ! Settings::show_selected() || ! $block instanceof WP_Block || ! $product instanceof WC_Product || ! $product->is_type( 'variable' ) || empty( $block->context['woocommerce/attributeName'] ) ) {
return $content;
}
$attribute = (string) $block->context['woocommerce/attributeName'];
$options = $product->get_variation_attributes()[ $attribute ] ?? array();
if ( null === Swatches::for_attribute( $product, $attribute, (array) $options ) ) {
return $content;
}
$at = strrpos( $content, '' );
if ( false === $at ) {
return $content;
}
$span = '';
return substr( $content, 0, $at ) . $span . substr( $content, $at );
}
}