hyva-alpine-component について
Hyvä テーマ開発者向けに、Alpine CSP に対応する Alpine.js コンポーネントの書き方を説明するスキルです。コンポーネント登録、データバインディング、イベント処理、Hyvä ユーティリティを扱います。
主な機能
- 01Alpine CSP の構造と登録パターン
- 02インライン式や変更処理のメソッド化
- 03data 属性によるメソッド引数の受け渡し
- 04x-model の代わりとなるイベント処理
- 05Hyvä のフォーム、Cookie、DOM ユーティリティ
活用シーン
- 01決済ページ向け CSP 対応コンポーネントの作成
- 02入力欄、チェックボックス、セレクトのバインド
- 03Hyvä テーマでのカスタムイベント処理
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ hyva-themes/ hyva-ai-tools --skill hyva-alpine-componentインストール後、Claude Code で/hyva-alpine-componentを入力して読み込まれたか確認します。
詳細
Hyvä Alpine コンポーネント
概要
このスキルでは、Hyvä テーマで CSP に対応した Alpine.js コンポーネントを作成するためのガイダンスを提供します。Alpine CSP は、unsafe-eval CSP ディレクティブを使わずに動作する Alpine.js の専用ビルドです。このディレクティブは PCI-DSS 4.0 準拠に必要で、決済関連ページでは 2025 年 4 月 1 日から必須となります。
重要な原則: CSP 対応コードは、通常の Alpine ビルドと Alpine CSP ビルドの両方で動作します。将来に備え、Alpine コードはすべて CSP のパターンに従って記述してください。
CSP の制約の概要
| 機能 | 通常の 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" | 非対応 |
コンポーネント構造のパターン
Hyvä の Alpine コンポーネントはすべて、次の構造に従います。
<div x-data="initComponentName">
</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() ?>
重要な要件:
alpine:initイベントリスナー内でAlpine.data()を使ってコンストラクターを登録します- 登録の重複を防ぐため、
{once: true}を使用します <script>ブロックの後には毎回$hyvaCsp->registerInlineScript()を呼び出します- JavaScript 文字列内の PHP 値には
$escaper->escapeJs()を使用します - データ属性には
$escaper->escapeHtmlAttr()を使用します(escapeJsではありません)
コンストラクター関数
基本的な登録
function initMyComponent() {
return {
open: false
}
}
window.addEventListener('alpine:init', () => Alpine.data('initMyComponent', initMyComponent), {once: true})
名前付きグローバル関数を使う理由: コンストラクター関数は、Alpine.data() コールバック内に直接記述するのではなく、グローバルスコープで名前付き関数として宣言します。これにより、他のテンプレートから関数をプロキシ化して拡張できます。これは Hyvä Themes の拡張性機能であり、他のモジュールや子テーマが、Alpine に登録される前にこれらの関数をラップまたはオーバーライドできます。
複数のオブジェクトの合成
(hyva.modal などを使って)オブジェクトを組み合わせる場合は、コンストラクター内でスプレッド構文を使用します。
function initMyModal() {
return {
...hyva.modal.call(this),
...hyva.formValidation(this.$el),
customProperty: '',
customMethod() {
// Custom logic
}
};
}
合成した関数に Alpine のコンテキストを渡すには、.call(this) を使用します。
プロパティアクセスのパターン
ドット記法による値プロパティ
return {
item: {
is_visible: true,
title: 'Product'
}
}
<span x-show="item.is_visible" x-text="item.title"></span>
値の変換(否定、条件)
CSP ではインライン変換が許可されていません。代わりにメソッドを作成します。
誤り(CSP 非対応):
<span x-show="!item.deleted"></span>
<span x-text="item.title || item.value"></span>
正しい例:
<span x-show="isItemNotDeleted"></span>
<span x-text="itemLabel"></span>
return {
item: { deleted: false, title: '', value: '' },
isItemNotDeleted() {
return !this.item.deleted;
},
itemLabel() {
return this.item.title || this.item.value;
}
}
否定メソッドの省略記法
単純な真偽値の否定には、ブラケット記法を使用します。
return {
deleted: false,
['!deleted']() {
return !this.deleted;
}
}
<template x-if="!deleted">
<div>The item is present</div>
</template>
プロパティ変更のパターン
値の変更をメソッドに切り出す
誤り(CSP 非対応):
<button @click="open = !open">Toggle</button>
正しい例:
<button @click="toggle">Toggle</button>
return {
open: false,
toggle() {
this.open = !this.open;
}
}
データセットによる引数の受け渡し
誤り(CSP 非対応):
<button @click="selectItem(123)">Select</button>
正しい例:
<button @click="selectItem" data-item-id="<?= $escaper->escapeHtmlAttr($itemId) ?>">Select</button>
return {
selected: null,
selectItem() {
this.selected = this.$el.dataset.itemId;
}
}
重要: データ属性には escapeJs ではなく escapeHtmlAttr を使用してください。
メソッド内でのイベント変数およびループ変数へのアクセス
メソッドから Alpine の特別なプロパティにアクセスできます。
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}`;
}
}
x-model の代替方法
x-model は Alpine CSP では利用できません。代わりに双方向バインディングのパターンを使用します。
テキスト入力
<input type="text"
:value="username"
@input="setUsername">
return {
username: '',
setUsername() {
this.username = this.$event.target.value;
}
}
数値入力
数値には hyva.safeParseNumber() を使用します。
return {
quantity: 1,
setQuantity() {
this.quantity = hyva.safeParseNumber(this.$event.target.value);
}
}
テキストエリア
<textarea @input="setComment" x-text="comment"></textarea>
return {
comment: '',
setComment() {
this.comment = this.$event.target.value;
}
}
チェックボックス
<input type="checkbox"
:checked="isSubscribed"
@change="toggleSubscribed">
return {
isSubscribed: false,
toggleSubscribed() {
this.isSubscribed = this.$event.target.checked;
}
}
チェックボックスの配列
<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>
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 @change="setCountry">
<template x-for="country in countries" :key="country.code">
<option :value="country.code"
:selected="isCountrySelected"
x-text="country.name"></option>
</template>
</select>
return {
selectedCountry: '',
isCountrySelected() {
return this.selectedCountry === this.country.code;
},
setCountry() {
this.selectedCountry = this.$event.target.value;
}
}
x-for のパターン
基本的な反復処理
<template x-for="(product, index) in products" :key="index">
<div x-text="product.name"></div>
</template>
ループ内でのメソッドの使用
ループ変数(product、index)はメソッドからアクセスできます。
<template x-for="(product, index) in products" :key="index">
<span :class="getItemClasses" @click="goToProduct" x-text="product.name"></span>
</template>
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}`;
}
}
値プロバイダーとしての関数
値プロバイダーには、(括弧を付けずに呼び出す)メソッドも指定できます。
<template x-for="(item, index) in getFilteredItems" :key="index">
<div x-text="item.name"></div>
</template>
return {
items: [],
filter: '',
getFilteredItems() {
return this.items.filter(item => item.name.includes(this.filter));
}
}
注: 範囲の反復処理(x-for="i in 10")は Alpine CSP ではサポートされていません。
Hyva ユーティリティ関数
グローバルの hyva オブジェクトは、次のユーティリティを提供します。
フォームとセキュリティ
hyva.getFormKey()- POST リクエスト用のフォームキーを取得または生成しますhyva.getUenc()- リダイレクト用に現在の URL を Base64 エンコードしますhyva.postForm({action, data, skipUenc})- POST フォームをプログラムから送信します
Cookie
hyva.getCookie(name)- Cookie の値を取得します(同意設定に従います)hyva.setCookie(name, value, days, skipSetDomain)- Cookie を設定しますhyva.setSessionCookie(name, value, skipSetDomain)- セッション Cookie を設定します
書式設定
hyva.formatPrice(value, showSign, options)- 通貨を書式設定しますhyva.str(template, ...args)- %1、%2 のプレースホルダーを使った文字列補間hyva.strf(template, ...args)- 0 始まりの文字列補間(%0、%1)
数値
hyva.safeParseNumber(rawValue)- 数値を安全に解析します(x-model.number の置き換え用)
DOM
hyva.replaceDomElement(selector, content)- DOM 要素を HTML コンテンツで置き換えますhyva.trapFocus(rootElement)- 要素内にフォーカスを閉じ込めます(モーダル用)hyva.releaseFocus(rootElement)- フォーカストラップを解除します
ストレージ
hyva.getBrowserStorage()- localStorage/sessionStorage を安全に取得します
Boolean オブジェクトヘルパー
トグルコンポーネントでは、hyva.createBooleanObject を使用します。
function initToggle() {
return {
...hyva.createBooleanObject('open', false),
// Additional methods
};
}
次のメソッドが生成されます:open()、notOpen()、toggleOpen()、setOpenTrue()、setOpenFalse()
Alpine の初期化
hyva.alpineInitialized(fn) // Run callback after Alpine initializes
イベントパターン
カスタムイベントのリスニング
<div x-data="initMyComponent"
@private-content-loaded.window="onPrivateContentLoaded"
@update-gallery.window="onGalleryUpdate">
return {
onPrivateContentLoaded() {
const data = this.$event.detail.data;
// Handle customer data
},
onGalleryUpdate() {
const images = this.$event.detail;
this.images = images;
}
}
イベントのディスパッチ
return {
updateQuantity() {
this.qty = newValue;
this.$dispatch('update-qty-' + this.productId, this.qty);
}
}
よく使われる Hyvä イベント
private-content-loaded- 顧客セクションのデータが読み込まれましたreload-customer-section-data- 顧客データの更新をリクエストしますupdate-gallery- 商品ギャラリーの画像が変更されましたreset-gallery- ギャラリーを初期状態にリセットします
イベントリスナーオブジェクトパターン
複数の window/document イベントリスナーには、x-bind パターンを使用します。
<div x-data="initGallery" x-bind="eventListeners">
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();
}
}
}
動的クラスパターン
メソッドからクラスオブジェクトを返します。
<div :class="containerClasses">
return {
fullscreen: false,
containerClasses() {
return {
'w-full h-full fixed top-0 left-0 bg-white z-50': this.fullscreen,
'relative': !this.fullscreen
};
}
}
PHP データをコンポーネントに渡す
データ属性経由
<div x-data="initProductList"
data-products="<?= $escaper->escapeHtmlAttr(json_encode($products)) ?>"
data-config="<?= $escaper->escapeHtmlAttr(json_encode($config)) ?>">
return {
products: [],
config: {},
init() {
this.products = JSON.parse(this.$root.dataset.products || '[]');
this.config = JSON.parse(this.$root.dataset.config || '{}');
}
}
インライン JavaScript 経由(エスケープあり)
function initComponent() {
return {
productId: '<?= (int) $product->getId() ?>',
productName: '<?= $escaper->escapeJs($product->getName()) ?>',
config: <?= /* @noEscape */ json_encode($config) ?>
}
}
完全な例:数量セレクター
<?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() ?>
参考資料
- 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