hyva-alpine-component
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseHyvä 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 CSP directive, which is required for PCI-DSS 4.0 compliance on payment-related pages (mandatory from April 1, 2025).
unsafe-evalKey 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的一个特殊构建版本,无需使用 CSP指令,这对于支付相关页面符合PCI-DSS 4.0合规要求是必需的(自2025年4月1日起强制执行)。
unsafe-eval核心原则: 兼容CSP的代码可在标准Alpine构建和Alpine CSP构建中运行。为了面向未来,请使用CSP模式编写所有Alpine代码。
CSP Constraints Summary
CSP约束摘要
| Capability | Standard Alpine | Alpine CSP |
|---|---|---|
| Property reads | | Same |
| Negation | | Method: |
| Mutations | | Method: |
| Method args | | Dataset: |
| Available | Not supported - use |
| Range iteration | | Not supported |
| 功能 | 标准Alpine版本 | Alpine CSP版本 |
|---|---|---|
| 属性读取 | | 相同 |
| 取反操作 | | 方法实现: |
| 变更操作 | | 方法实现: |
| 方法参数 | | 数据集实现: |
| 支持 | 不支持 - 使用 |
| 范围迭代 | | 不支持 |
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:
- Register constructor with inside
Alpine.data()event listeneralpine:init - Use to prevent duplicate registrations
{once: true} - Call after every
$hyvaCsp->registerInlineScript()block<script> - Use for PHP values in JavaScript strings
$escaper->escapeJs() - Use for data attributes (not
$escaper->escapeHtmlAttr())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() ?>关键要求:
- 在事件监听器内使用
alpine:init注册构造函数Alpine.data() - 使用防止重复注册
{once: true} - 在每个块后调用
<script>$hyvaCsp->registerInlineScript() - 对JavaScript字符串中的PHP值使用
$escaper->escapeJs() - 对数据属性使用(而非
$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 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.
Alpine.data()javascript
function initMyComponent() {
return {
open: false
}
}
window.addEventListener('alpine:init', () => Alpine.data('initMyComponent', initMyComponent), {once: true})为什么使用命名全局函数? 构造函数在全局作用域中声明为命名函数(而非内联在回调中),这样它们可以在其他模板中被代理和扩展。这是Hyvä Themes的一个可扩展性特性——其他模块或子主题可以在这些函数注册到Alpine之前对其进行包装或重写。
Alpine.data()Composing Multiple Objects
组合多个对象
When combining objects (e.g., with ), use spread syntax inside the constructor:
hyva.modaljavascript
function initMyModal() {
return {
...hyva.modal.call(this),
...hyva.formValidation(this.$el),
customProperty: '',
customMethod() {
// Custom logic
}
};
}Use to pass Alpine context to composed functions.
.call(this)当组合对象时(例如与组合),在构造函数内使用展开语法:
hyva.modaljavascript
function initMyModal() {
return {
...hyva.modal.call(this),
...hyva.formValidation(this.$el),
customProperty: '',
customMethod() {
// 自定义逻辑
}
};
}使用将Alpine上下文传递给组合函数。
.call(this)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 for data attributes, not .
escapeHtmlAttrescapeJs错误写法(不兼容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;
}
}重要提示: 对数据属性使用,而非。
escapeHtmlAttrescapeJsAccessing 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-modelx-modelText 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 for numeric values:
hyva.safeParseNumber()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 (, ) are accessible in methods:
productindexhtml
<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}`;
}
}循环变量(、)可在方法中访问:
productindexhtml
<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 () is not supported in Alpine CSP.
x-for="i in 10"值提供者可以是一个方法(无需加括号调用):
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));
}
}注意: 范围迭代()在Alpine CSP中不受支持。
x-for="i in 10"Hyva Utility Functions
Hyva工具函数
The global object provides these utilities:
hyva全局对象提供以下工具函数:
hyvaForm and Security
表单与安全
- - Get/generate form key for POST requests
hyva.getFormKey() - - Base64 encode current URL for redirects
hyva.getUenc() - - Submit a POST form programmatically
hyva.postForm({action, data, skipUenc})
- - 获取/生成POST请求所需的表单密钥
hyva.getFormKey() - - 对当前URL进行Base64编码用于重定向
hyva.getUenc() - - 以编程方式提交POST表单
hyva.postForm({action, data, skipUenc})
Cookies
Cookie
- - Get cookie value (respects consent)
hyva.getCookie(name) - - Set cookie
hyva.setCookie(name, value, days, skipSetDomain) - - Set session cookie
hyva.setSessionCookie(name, value, skipSetDomain)
- - 获取Cookie值(遵循隐私同意设置)
hyva.getCookie(name) - - 设置Cookie
hyva.setCookie(name, value, days, skipSetDomain) - - 设置会话Cookie
hyva.setSessionCookie(name, value, skipSetDomain)
Formatting
格式化
- - Format currency
hyva.formatPrice(value, showSign, options) - - String interpolation with %1, %2 placeholders
hyva.str(template, ...args) - - Zero-based string interpolation (%0, %1)
hyva.strf(template, ...args)
- - 格式化货币
hyva.formatPrice(value, showSign, options) - - 使用%1、%2占位符进行字符串插值
hyva.str(template, ...args) - - 从零开始的字符串插值(%0、%1)
hyva.strf(template, ...args)
Numbers
数字处理
- - Parse number safely (for x-model.number replacement)
hyva.safeParseNumber(rawValue)
- - 安全解析数字(替代x-model.number)
hyva.safeParseNumber(rawValue)
DOM
DOM操作
- - Replace DOM element with HTML content
hyva.replaceDomElement(selector, content) - - Trap focus within element (for modals)
hyva.trapFocus(rootElement) - - Release focus trap
hyva.releaseFocus(rootElement)
- - 用HTML内容替换DOM元素
hyva.replaceDomElement(selector, content) - - 在元素内捕获焦点(用于模态框)
hyva.trapFocus(rootElement) - - 释放焦点捕获
hyva.releaseFocus(rootElement)
Storage
存储
- - Get localStorage/sessionStorage safely
hyva.getBrowserStorage()
- - 安全获取localStorage/sessionStorage
hyva.getBrowserStorage()
Boolean Object Helper
布尔对象助手
For toggle components, use :
hyva.createBooleanObjectjavascript
function initToggle() {
return {
...hyva.createBooleanObject('open', false),
// Additional methods
};
}This generates: , , , ,
open()notOpen()toggleOpen()setOpenTrue()setOpenFalse()对于切换组件,使用:
hyva.createBooleanObjectjavascript
function initToggle() {
return {
...hyva.createBooleanObject('open', false),
// 额外方法
};
}这会生成以下方法:、、、、
open()notOpen()toggleOpen()setOpenTrue()setOpenFalse()Alpine Initialization
Alpine初始化
javascript
hyva.alpineInitialized(fn) // Run callback after Alpine initializesjavascript
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ä事件
- - Customer section data loaded
private-content-loaded - - Request customer data refresh
reload-customer-section-data - - Product gallery images changed
update-gallery - - Reset gallery to initial state
reset-gallery
- - 客户区域数据加载完成
private-content-loaded - - 请求刷新客户数据
reload-customer-section-data - - 产品图库图片变更
update-gallery - - 重置图库至初始状态
reset-gallery
Event Listeners Object Pattern
事件监听器对象模式
For multiple window/document event listeners, use the pattern:
x-bindhtml
<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-bindhtml
<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
参考资料
- Hyvä CSP Documentation: https://docs.hyva.io/hyva-themes/writing-code/csp/alpine-csp.html
- Alpine.js Documentation: https://alpinejs.dev/
- Example components:
vendor/hyva-themes/magento2-default-theme-csp/ - Core utilities:
vendor/hyva-themes/magento2-theme-module/src/view/frontend/templates/page/js/hyva.phtml
- Hyvä CSP文档: https://docs.hyva.io/hyva-themes/writing-code/csp/alpine-csp.html
- Alpine.js文档: https://alpinejs.dev/
- 示例组件:
vendor/hyva-themes/magento2-default-theme-csp/ - 核心工具函数:
vendor/hyva-themes/magento2-theme-module/src/view/frontend/templates/page/js/hyva.phtml