hyva-alpine-component

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Hyvä Alpine Component

Hyvä Alpine组件

Overview

概述

This skill provides guidance for writing CSP-compatible Alpine.js components in Hyvä themes. Alpine CSP is a specialized Alpine.js build that operates without the
unsafe-eval
CSP directive, which is required for PCI-DSS 4.0 compliance on payment-related pages (mandatory from April 1, 2025).
Key principle: CSP-compatible code functions in both standard and Alpine CSP builds. Write all Alpine code using CSP patterns for future-proofing.
本技能为在Hyvä主题中编写兼容CSP的Alpine.js组件提供指导。Alpine CSP是Alpine.js的一个特殊构建版本,无需使用
unsafe-eval
CSP指令,这对于支付相关页面符合PCI-DSS 4.0合规要求是必需的(自2025年4月1日起强制执行)。
核心原则: 兼容CSP的代码可在标准Alpine构建和Alpine CSP构建中运行。为了面向未来,请使用CSP模式编写所有Alpine代码。

CSP Constraints Summary

CSP约束摘要

CapabilityStandard AlpineAlpine CSP
Property reads
x-show="open"
Same
Negation
x-show="!open"
Method:
x-show="isNotOpen"
Mutations
@click="open = false"
Method:
@click="close"
Method args
@click="setTab('info')"
Dataset:
@click="setTab" data-tab="info"
x-model
AvailableNot supported - use
:value
+
@input
Range iteration
x-for="i in 10"
Not supported
功能标准Alpine版本Alpine CSP版本
属性读取
x-show="open"
相同
取反操作
x-show="!open"
方法实现:
x-show="isNotOpen"
变更操作
@click="open = false"
方法实现:
@click="close"
方法参数
@click="setTab('info')"
数据集实现:
@click="setTab" data-tab="info"
x-model
支持不支持 - 使用
:value
+
@input
替代
范围迭代
x-for="i in 10"
不支持

Component Structure Pattern

组件结构模式

Every Alpine component in Hyvä follows this structure:
html
<div x-data="initComponentName">
    <!-- Template content -->
</div>
<script>
    function initComponentName() {
        return {
            // Properties
            propertyName: initialValue,

            // Lifecycle
            init() {
                // Called when component initializes
            },

            // Methods for state access
            isPropertyTrue() {
                return this.propertyName === true;
            },

            // Methods for mutations
            setPropertyValue() {
                this.propertyName = this.$event.target.value;
            }
        }
    }
    window.addEventListener('alpine:init', () => Alpine.data('initComponentName', initComponentName), {once: true})
</script>
<?php $hyvaCsp->registerInlineScript() ?>
Critical requirements:
  1. Register constructor with
    Alpine.data()
    inside
    alpine:init
    event listener
  2. Use
    {once: true}
    to prevent duplicate registrations
  3. Call
    $hyvaCsp->registerInlineScript()
    after every
    <script>
    block
  4. Use
    $escaper->escapeJs()
    for PHP values in JavaScript strings
  5. Use
    $escaper->escapeHtmlAttr()
    for data attributes (not
    escapeJs
    )
Hyva主题中的每个Alpine组件都遵循以下结构:
html
<div x-data="initComponentName">
    <!-- 模板内容 -->
</div>
<script>
    function initComponentName() {
        return {
            // 属性
            propertyName: initialValue,

            // 生命周期
            init() {
                // 组件初始化时调用
            },

            // 状态访问方法
            isPropertyTrue() {
                return this.propertyName === true;
            },

            // 变更操作方法
            setPropertyValue() {
                this.propertyName = this.$event.target.value;
            }
        }
    }
    window.addEventListener('alpine:init', () => Alpine.data('initComponentName', initComponentName), {once: true})
</script>
<?php $hyvaCsp->registerInlineScript() ?>
关键要求:
  1. alpine:init
    事件监听器内使用
    Alpine.data()
    注册构造函数
  2. 使用
    {once: true}
    防止重复注册
  3. 在每个
    <script>
    块后调用
    $hyvaCsp->registerInlineScript()
  4. 对JavaScript字符串中的PHP值使用
    $escaper->escapeJs()
  5. 对数据属性使用
    $escaper->escapeHtmlAttr()
    (而非
    escapeJs

Constructor Functions

构造函数

Basic Registration

基础注册

javascript
function initMyComponent() {
    return {
        open: false
    }
}
window.addEventListener('alpine:init', () => Alpine.data('initMyComponent', initMyComponent), {once: true})
Why named global functions? Constructor functions are declared as named functions in global scope (not inlined in the
Alpine.data()
callback) so they can be proxied and extended in other templates. This is an extensibility feature of Hyvä Themes - other modules or child themes can wrap or override these functions before they are registered with Alpine.
javascript
function initMyComponent() {
    return {
        open: false
    }
}
window.addEventListener('alpine:init', () => Alpine.data('initMyComponent', initMyComponent), {once: true})
为什么使用命名全局函数? 构造函数在全局作用域中声明为命名函数(而非内联在
Alpine.data()
回调中),这样它们可以在其他模板中被代理和扩展。这是Hyvä Themes的一个可扩展性特性——其他模块或子主题可以在这些函数注册到Alpine之前对其进行包装或重写。

Composing Multiple Objects

组合多个对象

When combining objects (e.g., with
hyva.modal
), use spread syntax inside the constructor:
javascript
function initMyModal() {
    return {
        ...hyva.modal.call(this),
        ...hyva.formValidation(this.$el),
        customProperty: '',
        customMethod() {
            // Custom logic
        }
    };
}
Use
.call(this)
to pass Alpine context to composed functions.
当组合对象时(例如与
hyva.modal
组合),在构造函数内使用展开语法:
javascript
function initMyModal() {
    return {
        ...hyva.modal.call(this),
        ...hyva.formValidation(this.$el),
        customProperty: '',
        customMethod() {
            // 自定义逻辑
        }
    };
}
使用
.call(this)
将Alpine上下文传递给组合函数。

Property Access Patterns

属性访问模式

Value Properties with Dot Notation

使用点表示法的属性值

javascript
return {
    item: {
        is_visible: true,
        title: 'Product'
    }
}
html
<span x-show="item.is_visible" x-text="item.title"></span>
javascript
return {
    item: {
        is_visible: true,
        title: '产品'
    }
}
html
<span x-show="item.is_visible" x-text="item.title"></span>

Transforming Values (Negation, Conditions)

值转换(取反、条件判断)

CSP does not allow inline transformations. Create methods instead:
Wrong (CSP incompatible):
html
<span x-show="!item.deleted"></span>
<span x-text="item.title || item.value"></span>
Correct:
html
<span x-show="isItemNotDeleted"></span>
<span x-text="itemLabel"></span>
javascript
return {
    item: { deleted: false, title: '', value: '' },

    isItemNotDeleted() {
        return !this.item.deleted;
    },
    itemLabel() {
        return this.item.title || this.item.value;
    }
}
CSP不允许内联转换逻辑,请改为创建方法:
错误写法(不兼容CSP):
html
<span x-show="!item.deleted"></span>
<span x-text="item.title || item.value"></span>
正确写法:
html
<span x-show="isItemNotDeleted"></span>
<span x-text="itemLabel"></span>
javascript
return {
    item: { deleted: false, title: '', value: '' },

    isItemNotDeleted() {
        return !this.item.deleted;
    },
    itemLabel() {
        return this.item.title || this.item.value;
    }
}

Negation Method Shorthand

取反方法简写

For simple boolean negation, use bracket notation:
javascript
return {
    deleted: false,
    ['!deleted']() {
        return !this.deleted;
    }
}
html
<template x-if="!deleted">
    <div>The item is present</div>
</template>
对于简单的布尔值取反,使用括号表示法:
javascript
return {
    deleted: false,
    ['!deleted']() {
        return !this.deleted;
    }
}
html
<template x-if="!deleted">
    <div>该项目已存在</div>
</template>

Property Mutation Patterns

属性变更模式

Extract Mutations to Methods

将变更逻辑提取到方法中

Wrong (CSP incompatible):
html
<button @click="open = !open">Toggle</button>
Correct:
html
<button @click="toggle">Toggle</button>
javascript
return {
    open: false,
    toggle() {
        this.open = !this.open;
    }
}
错误写法(不兼容CSP):
html
<button @click="open = !open">切换</button>
正确写法:
html
<button @click="toggle">切换</button>
javascript
return {
    open: false,
    toggle() {
        this.open = !this.open;
    }
}

Passing Arguments via Dataset

通过数据集传递参数

Wrong (CSP incompatible):
html
<button @click="selectItem(123)">Select</button>
Correct:
html
<button @click="selectItem" data-item-id="<?= $escaper->escapeHtmlAttr($itemId) ?>">Select</button>
javascript
return {
    selected: null,
    selectItem() {
        this.selected = this.$el.dataset.itemId;
    }
}
Important: Use
escapeHtmlAttr
for data attributes, not
escapeJs
.
错误写法(不兼容CSP):
html
<button @click="selectItem(123)">选择</button>
正确写法:
html
<button @click="selectItem" data-item-id="<?= $escaper->escapeHtmlAttr($itemId) ?>">选择</button>
javascript
return {
    selected: null,
    selectItem() {
        this.selected = this.$el.dataset.itemId;
    }
}
重要提示: 对数据属性使用
escapeHtmlAttr
,而非
escapeJs

Accessing Event and Loop Variables in Methods

在方法中访问事件和循环变量

Methods can access Alpine's special properties:
javascript
return {
    onInput() {
        // Access event
        const value = this.$event.target.value;
        this.inputValue = value;
    },
    getItemUrl() {
        // Access x-for loop variable
        return `${BASE_URL}/product/id/${this.item.id}`;
    }
}
方法可以访问Alpine的特殊属性:
javascript
return {
    onInput() {
        // 访问事件对象
        const value = this.$event.target.value;
        this.inputValue = value;
    },
    getItemUrl() {
        // 访问x-for循环变量
        return `${BASE_URL}/product/id/${this.item.id}`;
    }
}

x-model Alternatives

x-model替代方案

x-model
is not available in Alpine CSP. Use two-way binding patterns instead.
x-model
在Alpine CSP中不可用,请使用双向绑定替代方案。

Text Inputs

文本输入框

html
<input type="text"
       :value="username"
       @input="setUsername">
javascript
return {
    username: '',
    setUsername() {
        this.username = this.$event.target.value;
    }
}
html
<input type="text"
       :value="username"
       @input="setUsername">
javascript
return {
    username: '',
    setUsername() {
        this.username = this.$event.target.value;
    }
}

Number Inputs

数字输入框

Use
hyva.safeParseNumber()
for numeric values:
javascript
return {
    quantity: 1,
    setQuantity() {
        this.quantity = hyva.safeParseNumber(this.$event.target.value);
    }
}
对数值类型使用
hyva.safeParseNumber()
javascript
return {
    quantity: 1,
    setQuantity() {
        this.quantity = hyva.safeParseNumber(this.$event.target.value);
    }
}

Textarea

文本域

html
<textarea @input="setComment" x-text="comment"></textarea>
javascript
return {
    comment: '',
    setComment() {
        this.comment = this.$event.target.value;
    }
}
html
<textarea @input="setComment" x-text="comment"></textarea>
javascript
return {
    comment: '',
    setComment() {
        this.comment = this.$event.target.value;
    }
}

Checkboxes

复选框

html
<input type="checkbox"
       :checked="isSubscribed"
       @change="toggleSubscribed">
javascript
return {
    isSubscribed: false,
    toggleSubscribed() {
        this.isSubscribed = this.$event.target.checked;
    }
}
html
<input type="checkbox"
       :checked="isSubscribed"
       @change="toggleSubscribed">
javascript
return {
    isSubscribed: false,
    toggleSubscribed() {
        this.isSubscribed = this.$event.target.checked;
    }
}

Checkbox Arrays

复选框数组

html
<template x-for="option in options" :key="option.id">
    <input type="checkbox"
           :value="option.id"
           :checked="isOptionSelected"
           @change="toggleOption"
           :data-option-id="option.id">
</template>
javascript
return {
    selectedOptions: [],
    isOptionSelected() {
        return this.selectedOptions.includes(this.option.id);
    },
    toggleOption() {
        const optionId = this.$el.dataset.optionId;
        const index = this.selectedOptions.indexOf(optionId);
        if (index === -1) {
            this.selectedOptions.push(optionId);
        } else {
            this.selectedOptions.splice(index, 1);
        }
    }
}
html
<template x-for="option in options" :key="option.id">
    <input type="checkbox"
           :value="option.id"
           :checked="isOptionSelected"
           @change="toggleOption"
           :data-option-id="option.id">
</template>
javascript
return {
    selectedOptions: [],
    isOptionSelected() {
        return this.selectedOptions.includes(this.option.id);
    },
    toggleOption() {
        const optionId = this.$el.dataset.optionId;
        const index = this.selectedOptions.indexOf(optionId);
        if (index === -1) {
            this.selectedOptions.push(optionId);
        } else {
            this.selectedOptions.splice(index, 1);
        }
    }
}

Select Elements

选择框元素

html
<select @change="setCountry">
    <template x-for="country in countries" :key="country.code">
        <option :value="country.code"
                :selected="isCountrySelected"
                x-text="country.name"></option>
    </template>
</select>
javascript
return {
    selectedCountry: '',
    isCountrySelected() {
        return this.selectedCountry === this.country.code;
    },
    setCountry() {
        this.selectedCountry = this.$event.target.value;
    }
}
html
<select @change="setCountry">
    <template x-for="country in countries" :key="country.code">
        <option :value="country.code"
                :selected="isCountrySelected"
                x-text="country.name"></option>
    </template>
</select>
javascript
return {
    selectedCountry: '',
    isCountrySelected() {
        return this.selectedCountry === this.country.code;
    },
    setCountry() {
        this.selectedCountry = this.$event.target.value;
    }
}

x-for Patterns

x-for模式

Basic Iteration

基础迭代

html
<template x-for="(product, index) in products" :key="index">
    <div x-text="product.name"></div>
</template>
html
<template x-for="(product, index) in products" :key="index">
    <div x-text="product.name"></div>
</template>

Using Methods in Loops

在循环中使用方法

Loop variables (
product
,
index
) are accessible in methods:
html
<template x-for="(product, index) in products" :key="index">
    <span :class="getItemClasses" @click="goToProduct" x-text="product.name"></span>
</template>
javascript
return {
    products: [],
    getItemClasses() {
        return {
            'font-bold': this.index === 0,
            'text-gray-500': this.product.disabled
        };
    },
    goToProduct() {
        window.location.href = `${BASE_URL}/product/${this.product.url_key}`;
    }
}
循环变量(
product
index
)可在方法中访问:
html
<template x-for="(product, index) in products" :key="index">
    <span :class="getItemClasses" @click="goToProduct" x-text="product.name"></span>
</template>
javascript
return {
    products: [],
    getItemClasses() {
        return {
            'font-bold': this.index === 0,
            'text-gray-500': this.product.disabled
        };
    },
    goToProduct() {
        window.location.href = `${BASE_URL}/product/${this.product.url_key}`;
    }
}

Function as Value Provider

使用方法作为值提供者

The value provider can be a method (called without parentheses):
html
<template x-for="(item, index) in getFilteredItems" :key="index">
    <div x-text="item.name"></div>
</template>
javascript
return {
    items: [],
    filter: '',
    getFilteredItems() {
        return this.items.filter(item => item.name.includes(this.filter));
    }
}
Note: Range iteration (
x-for="i in 10"
) is not supported in Alpine CSP.
值提供者可以是一个方法(无需加括号调用):
html
<template x-for="(item, index) in getFilteredItems" :key="index">
    <div x-text="item.name"></div>
</template>
javascript
return {
    items: [],
    filter: '',
    getFilteredItems() {
        return this.items.filter(item => item.name.includes(this.filter));
    }
}
注意: 范围迭代(
x-for="i in 10"
)在Alpine CSP中不受支持。

Hyva Utility Functions

Hyva工具函数

The global
hyva
object provides these utilities:
全局
hyva
对象提供以下工具函数:

Form and Security

表单与安全

  • hyva.getFormKey()
    - Get/generate form key for POST requests
  • hyva.getUenc()
    - Base64 encode current URL for redirects
  • hyva.postForm({action, data, skipUenc})
    - Submit a POST form programmatically
  • hyva.getFormKey()
    - 获取/生成POST请求所需的表单密钥
  • hyva.getUenc()
    - 对当前URL进行Base64编码用于重定向
  • hyva.postForm({action, data, skipUenc})
    - 以编程方式提交POST表单

Cookies

Cookie

  • hyva.getCookie(name)
    - Get cookie value (respects consent)
  • hyva.setCookie(name, value, days, skipSetDomain)
    - Set cookie
  • hyva.setSessionCookie(name, value, skipSetDomain)
    - Set session cookie
  • hyva.getCookie(name)
    - 获取Cookie值(遵循隐私同意设置)
  • hyva.setCookie(name, value, days, skipSetDomain)
    - 设置Cookie
  • hyva.setSessionCookie(name, value, skipSetDomain)
    - 设置会话Cookie

Formatting

格式化

  • hyva.formatPrice(value, showSign, options)
    - Format currency
  • hyva.str(template, ...args)
    - String interpolation with %1, %2 placeholders
  • hyva.strf(template, ...args)
    - Zero-based string interpolation (%0, %1)
  • hyva.formatPrice(value, showSign, options)
    - 格式化货币
  • hyva.str(template, ...args)
    - 使用%1、%2占位符进行字符串插值
  • hyva.strf(template, ...args)
    - 从零开始的字符串插值(%0、%1)

Numbers

数字处理

  • hyva.safeParseNumber(rawValue)
    - Parse number safely (for x-model.number replacement)
  • hyva.safeParseNumber(rawValue)
    - 安全解析数字(替代x-model.number)

DOM

DOM操作

  • hyva.replaceDomElement(selector, content)
    - Replace DOM element with HTML content
  • hyva.trapFocus(rootElement)
    - Trap focus within element (for modals)
  • hyva.releaseFocus(rootElement)
    - Release focus trap
  • hyva.replaceDomElement(selector, content)
    - 用HTML内容替换DOM元素
  • hyva.trapFocus(rootElement)
    - 在元素内捕获焦点(用于模态框)
  • hyva.releaseFocus(rootElement)
    - 释放焦点捕获

Storage

存储

  • hyva.getBrowserStorage()
    - Get localStorage/sessionStorage safely
  • hyva.getBrowserStorage()
    - 安全获取localStorage/sessionStorage

Boolean Object Helper

布尔对象助手

For toggle components, use
hyva.createBooleanObject
:
javascript
function initToggle() {
    return {
        ...hyva.createBooleanObject('open', false),
        // Additional methods
    };
}
This generates:
open()
,
notOpen()
,
toggleOpen()
,
setOpenTrue()
,
setOpenFalse()
对于切换组件,使用
hyva.createBooleanObject
javascript
function initToggle() {
    return {
        ...hyva.createBooleanObject('open', false),
        // 额外方法
    };
}
这会生成以下方法:
open()
notOpen()
toggleOpen()
setOpenTrue()
setOpenFalse()

Alpine Initialization

Alpine初始化

javascript
hyva.alpineInitialized(fn)  // Run callback after Alpine initializes
javascript
hyva.alpineInitialized(fn)  // 在Alpine初始化完成后运行回调函数

Event Patterns

事件模式

Listening to Custom Events

监听自定义事件

html
<div x-data="initMyComponent"
     @private-content-loaded.window="onPrivateContentLoaded"
     @update-gallery.window="onGalleryUpdate">
javascript
return {
    onPrivateContentLoaded() {
        const data = this.$event.detail.data;
        // Handle customer data
    },
    onGalleryUpdate() {
        const images = this.$event.detail;
        this.images = images;
    }
}
html
<div x-data="initMyComponent"
     @private-content-loaded.window="onPrivateContentLoaded"
     @update-gallery.window="onGalleryUpdate">
javascript
return {
    onPrivateContentLoaded() {
        const data = this.$event.detail.data;
        // 处理客户数据
    },
    onGalleryUpdate() {
        const images = this.$event.detail;
        this.images = images;
    }
}

Dispatching Events

触发事件

javascript
return {
    updateQuantity() {
        this.qty = newValue;
        this.$dispatch('update-qty-' + this.productId, this.qty);
    }
}
javascript
return {
    updateQuantity() {
        this.qty = newValue;
        this.$dispatch('update-qty-' + this.productId, this.qty);
    }
}

Common Hyvä Events

常见Hyvä事件

  • private-content-loaded
    - Customer section data loaded
  • reload-customer-section-data
    - Request customer data refresh
  • update-gallery
    - Product gallery images changed
  • reset-gallery
    - Reset gallery to initial state
  • private-content-loaded
    - 客户区域数据加载完成
  • reload-customer-section-data
    - 请求刷新客户数据
  • update-gallery
    - 产品图库图片变更
  • reset-gallery
    - 重置图库至初始状态

Event Listeners Object Pattern

事件监听器对象模式

For multiple window/document event listeners, use the
x-bind
pattern:
html
<div x-data="initGallery" x-bind="eventListeners">
javascript
return {
    eventListeners: {
        ['@keydown.window.escape']() {
            if (!this.fullscreen) return;
            this.closeFullScreen();
        },
        ['@update-gallery.window'](event) {
            this.receiveImages(event.detail);
        },
        ['@keyup.arrow-right.window']() {
            if (!this.fullscreen) return;
            this.nextItem();
        }
    }
}
对于多个窗口/文档事件监听器,使用
x-bind
模式:
html
<div x-data="initGallery" x-bind="eventListeners">
javascript
return {
    eventListeners: {
        ['@keydown.window.escape']() {
            if (!this.fullscreen) return;
            this.closeFullScreen();
        },
        ['@update-gallery.window'](event) {
            this.receiveImages(event.detail);
        },
        ['@keyup.arrow-right.window']() {
            if (!this.fullscreen) return;
            this.nextItem();
        }
    }
}

Dynamic Classes Pattern

动态类模式

Return class objects from methods:
html
<div :class="containerClasses">
javascript
return {
    fullscreen: false,
    containerClasses() {
        return {
            'w-full h-full fixed top-0 left-0 bg-white z-50': this.fullscreen,
            'relative': !this.fullscreen
        };
    }
}
从方法中返回类对象:
html
<div :class="containerClasses">
javascript
return {
    fullscreen: false,
    containerClasses() {
        return {
            'w-full h-full fixed top-0 left-0 bg-white z-50': this.fullscreen,
            'relative': !this.fullscreen
        };
    }
}

Passing PHP Data to Components

向组件传递PHP数据

Via Data Attributes

通过数据属性

html
<div x-data="initProductList"
     data-products="<?= $escaper->escapeHtmlAttr(json_encode($products)) ?>"
     data-config="<?= $escaper->escapeHtmlAttr(json_encode($config)) ?>">
javascript
return {
    products: [],
    config: {},
    init() {
        this.products = JSON.parse(this.$root.dataset.products || '[]');
        this.config = JSON.parse(this.$root.dataset.config || '{}');
    }
}
html
<div x-data="initProductList"
     data-products="<?= $escaper->escapeHtmlAttr(json_encode($products)) ?>"
     data-config="<?= $escaper->escapeHtmlAttr(json_encode($config)) ?>">
javascript
return {
    products: [],
    config: {},
    init() {
        this.products = JSON.parse(this.$root.dataset.products || '[]');
        this.config = JSON.parse(this.$root.dataset.config || '{}');
    }
}

Via Inline JavaScript (with escaping)

通过内联JavaScript(带转义)

javascript
function initComponent() {
    return {
        productId: '<?= (int) $product->getId() ?>',
        productName: '<?= $escaper->escapeJs($product->getName()) ?>',
        config: <?= /* @noEscape */ json_encode($config) ?>
    }
}
javascript
function initComponent() {
    return {
        productId: '<?= (int) $product->getId() ?>',
        productName: '<?= $escaper->escapeJs($product->getName()) ?>',
        config: <?= /* @noEscape */ json_encode($config) ?>
    }
}

Complete Example: Quantity Selector

完整示例:数量选择器

php
<?php
declare(strict_types=1);

use Hyva\Theme\ViewModel\HyvaCsp;
use Magento\Framework\Escaper;

/** @var Escaper $escaper */
/** @var HyvaCsp $hyvaCsp */

$productId = (int) $product->getId();
$minQty = 1;
$maxQty = 100;
$defaultQty = 1;
?>
<div x-data="initQtySelector">
    <label for="qty-<?= $productId ?>" class="sr-only">
        <?= $escaper->escapeHtml(__('Quantity')) ?>
    </label>
    <div class="flex items-center">
        <button type="button"
                class="btn"
                @click="decrement"
                :disabled="isMinQty"
                :class="decrementClasses">
            -
        </button>
        <input type="number"
               id="qty-<?= $productId ?>"
               name="qty"
               :value="qty"
               @input="onInput"
               min="<?= $minQty ?>"
               max="<?= $maxQty ?>"
               class="form-input w-16 text-center">
        <button type="button"
                class="btn"
                @click="increment"
                :disabled="isMaxQty"
                :class="incrementClasses">
            +
        </button>
    </div>
</div>
<script>
    function initQtySelector() {
        return {
            qty: <?= (int) $defaultQty ?>,
            minQty: <?= (int) $minQty ?>,
            maxQty: <?= (int) $maxQty ?>,
            productId: '<?= $productId ?>',

            onInput() {
                let value = hyva.safeParseNumber(this.$event.target.value);
                if (value < this.minQty) value = this.minQty;
                if (value > this.maxQty) value = this.maxQty;
                this.qty = value;
                this.$dispatch('update-qty-' + this.productId, this.qty);
            },

            increment() {
                if (this.qty < this.maxQty) {
                    this.qty++;
                    this.$dispatch('update-qty-' + this.productId, this.qty);
                }
            },

            decrement() {
                if (this.qty > this.minQty) {
                    this.qty--;
                    this.$dispatch('update-qty-' + this.productId, this.qty);
                }
            },

            isMinQty() {
                return this.qty <= this.minQty;
            },

            isMaxQty() {
                return this.qty >= this.maxQty;
            },

            decrementClasses() {
                return { 'opacity-50 cursor-not-allowed': this.isMinQty() };
            },

            incrementClasses() {
                return { 'opacity-50 cursor-not-allowed': this.isMaxQty() };
            }
        }
    }
    window.addEventListener('alpine:init', () => Alpine.data('initQtySelector', initQtySelector), {once: true})
</script>
<?php $hyvaCsp->registerInlineScript() ?>
php
<?php
declare(strict_types=1);

use Hyva\Theme\ViewModel\HyvaCsp;
use Magento\Framework\Escaper;

/** @var Escaper $escaper */
/** @var HyvaCsp $hyvaCsp */

$productId = (int) $product->getId();
$minQty = 1;
$maxQty = 100;
$defaultQty = 1;
?>
<div x-data="initQtySelector">
    <label for="qty-<?= $productId ?>" class="sr-only">
        <?= $escaper->escapeHtml(__('数量')) ?>
    </label>
    <div class="flex items-center">
        <button type="button"
                class="btn"
                @click="decrement"
                :disabled="isMinQty"
                :class="decrementClasses">
            -
        </button>
        <input type="number"
               id="qty-<?= $productId ?>"
               name="qty"
               :value="qty"
               @input="onInput"
               min="<?= $minQty ?>"
               max="<?= $maxQty ?>"
               class="form-input w-16 text-center">
        <button type="button"
                class="btn"
                @click="increment"
                :disabled="isMaxQty"
                :class="incrementClasses">
            +
        </button>
    </div>
</div>
<script>
    function initQtySelector() {
        return {
            qty: <?= (int) $defaultQty ?>,
            minQty: <?= (int) $minQty ?>,
            maxQty: <?= (int) $maxQty ?>,
            productId: '<?= $productId ?>',

            onInput() {
                let value = hyva.safeParseNumber(this.$event.target.value);
                if (value < this.minQty) value = this.minQty;
                if (value > this.maxQty) value = this.maxQty;
                this.qty = value;
                this.$dispatch('update-qty-' + this.productId, this.qty);
            },

            increment() {
                if (this.qty < this.maxQty) {
                    this.qty++;
                    this.$dispatch('update-qty-' + this.productId, this.qty);
                }
            },

            decrement() {
                if (this.qty > this.minQty) {
                    this.qty--;
                    this.$dispatch('update-qty-' + this.productId, this.qty);
                }
            },

            isMinQty() {
                return this.qty <= this.minQty;
            },

            isMaxQty() {
                return this.qty >= this.maxQty;
            },

            decrementClasses() {
                return { 'opacity-50 cursor-not-allowed': this.isMinQty() };
            },

            incrementClasses() {
                return { 'opacity-50 cursor-not-allowed': this.isMaxQty() };
            }
        }
    }
    window.addEventListener('alpine:init', () => Alpine.data('initQtySelector', initQtySelector), {once: true})
</script>
<?php $hyvaCsp->registerInlineScript() ?>

References

参考资料

<!-- Copyright © Hyvä Themes https://hyva.io. All rights reserved. Licensed under OSL 3.0 -->
<!-- Copyright © Hyvä Themes https://hyva.io. All rights reserved. Licensed under OSL 3.0 -->