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 ); } }