mcpField
H
hyva-alpine-component

hyva-alpine-component

github.com/hyva-themes開発851.3k 回インストール13 週間前に更新
リポジトリ

hyva-alpine-component について

Hyvä テーマ開発者向けに、Alpine CSP に対応する Alpine.js コンポーネントの書き方を説明するスキルです。コンポーネント登録、データバインディング、イベント処理、Hyvä ユーティリティを扱います。

主な機能

  1. 01Alpine CSP の構造と登録パターン
  2. 02インライン式や変更処理のメソッド化
  3. 03data 属性によるメソッド引数の受け渡し
  4. 04x-model の代わりとなるイベント処理
  5. 05Hyvä のフォーム、Cookie、DOM ユーティリティ

活用シーン

  1. 01決済ページ向け CSP 対応コンポーネントの作成
  2. 02入力欄、チェックボックス、セレクトのバインド
  3. 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 の制約の概要

機能通常の AlpineAlpine 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() ?>

重要な要件:

  1. alpine:init イベントリスナー内で Alpine.data() を使ってコンストラクターを登録します
  2. 登録の重複を防ぐため、{once: true} を使用します
  3. <script> ブロックの後には毎回 $hyvaCsp->registerInlineScript() を呼び出します
  4. JavaScript 文字列内の PHP 値には $escaper->escapeJs() を使用します
  5. データ属性には $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() ?>

参考資料

コメント