【必読】なぜ今、Aura ではなく LWR を選ぶべきなのか?

【Spring ’26】LWRサイトに「ダークモード」を実装する:ExperiencePropertyTypeBundleによるテーマ管理コンポーネント作成ガイド

Experience Cloud (LWR) サイトにおいて、ブランドカラーに合わせた「ダークモード」や「テーマ切り替え」の実装は、UX向上のための重要な要件です。しかし、色設定やフォント設定など、管理項目が増えれば増えるほど、標準のプロパティパネルでは設定しきれなくなります。

Spring ’26 (API 66.0) で正式リリース(GA)となる ExperiencePropertyTypeBundle を使用すれば、複雑な設定項目を「構造化データ」として定義し、アコーディオンやタブを用いたリッチな設定画面を、驚くほど少ないコード量で実装できます。

ExperiencePropertyTypeBundle の基本について知りたい方は、以下の投稿をご覧ください。

今回は、全ページの色設定を一括管理する「Dark Mode Switch Button」の作成を通して、この最新メタデータの活用方法をハンズオン形式で解説します。

DXforce Point for Developers

今回の記事で作成したコンポーネントの全ソースコードをGitHubで公開しています。 ぜひ Clone して、あなたの組織で動かしてみてください。

作成するコンポーネントの構成

今回作成するのは、以下の3つの要素です。以前の手法(CPE)で必要だった「設定画面用の親LWC(Configurator)」は不要になります。

  1. ExperiencePropertyTypeBundle: 設定データの「型」と「画面レイアウト」を定義。
  2. Color Picker LWC: 色を選択するための部品(プロパティエディタ)。
  3. Main LWC: サイト上に配置され、CSS変数を書き換える本体。

ディレクトリ構成

force-app/main/default/
├── experiencePropertyTypeBundles/
│ └── themeSettingsType/ <-- 設定の「型」と「レイアウト」定義
│ ├── schema.json
│ └── design.json
├── lwc/
│ ├── removableColorPicker/ <-- カラーピッカー(部品)
│ │ ├── removableColorPicker.html
│ │ ├── removableColorPicker.js
│ │ └── removableColorPicker.js-meta.xml
│ └── darkModeSwitchButton/. <-- 画面に表示するLWC本体
│ ├── darkModeSwitchButton.html
│ ├── darkModeSwitchButton.js
│ ├── darkModeSwitchButton.css
│ └── darkModeSwitchButton.js-meta.xml

ステップ1:設定データの「型」を定義する (schema.json)

まずは、テーマ設定として保存したいデータの構造を定義します。
force-app/main/default/experiencePropertyTypeBundles/themeSettingsType フォルダを作成し、以下のファイルを作成します。

schema.json

ここでは、背景色やテキスト色、ボタンの色などを lightning__objectType として定義します。これがコンポーネントに渡されるデータの「正解」となります。

{
    "title": "Theme Settings Configuration",
    "description": "Configuration object for site colors and styles",
    "lightning:type": "lightning__objectType",
    "properties": {
        "colorRoot": { "lightning:type": "lightning__textType", "title": "ページ背景 (--dxp-g-root)" },
        "colorText": { "lightning:type": "lightning__textType", "title": "基本テキスト (--dxp-g-root-contrast)" },
        "colorBrand": { "lightning:type": "lightning__textType", "title": "ブランド色 (--dxp-g-brand)" },
        "colorBrandContrast": { "lightning:type": "lightning__textType", "title": "ブランド前景色 (--dxp-g-brand-contrast)" },
        "iconColorForeground": { "lightning:type": "lightning__textType", "title": "アイコン色 [Dark]" },
        "iconColorForegroundLight": { "lightning:type": "lightning__textType", "title": "アイコン色 [Light]" },
...

※ 実際には必要な項目数だけプロパティを追加します。

ステップ2:設定画面のレイアウトを定義する (design.json)

次に、Experience Builder上の設定パネルの見た目を定義します。ここが ExperiencePropertyTypeBundle の強力な点です。LWCでHTMLを書くことなく、lightning/accordionLayout を指定するだけで、整理された設定画面が構築できます。

design.json

各プロパティに対して、後述するカスタムエディタ c/removableColorPicker を割り当てます。

{
    "propertySheet": {
        "view": {
            "definition": "lightning/accordionLayout",
            "children": [
                {
                    "definition": "lightning/accordionSectionLayout",
                    "attributes": { "label": "基本設定" },
                    "children": [
                        { "definition": "lightning/propertyLayout", "attributes": { "property": "colorRoot" } },
                        { "definition": "lightning/propertyLayout", "attributes": { "property": "colorText" } },
                        { "definition": "lightning/propertyLayout", "attributes": { "property": "colorBrand" } },
                        { "definition": "lightning/propertyLayout", "attributes": { "property": "colorBrandContrast" } },
                        { "definition": "lightning/propertyLayout", "attributes": { "property": "iconColorForeground" } },
                        { "definition": "lightning/propertyLayout", "attributes": { "property": "iconColorForegroundLight" } }
                    ]
                },
...
            ]
        },
        "propertyRenderers": {
            "colorRoot": { "definition": "c/removableColorPicker" },
            "colorText": { "definition": "c/removableColorPicker" },
            "colorBrand": { "definition": "c/removableColorPicker" },
            "colorBrandContrast": { "definition": "c/removableColorPicker" },
            "iconColorForeground": { "definition": "c/removableColorPicker" },
            "iconColorForegroundLight": { "definition": "c/removableColorPicker" },
...

※ 実際には必要な項目数だけプロパティを追加します。

ステップ3:入力部品(カラーピッカー)を作成する

設定画面で使う「値を削除できるカラーピッカー」を作成します。 このコンポーネントは lightning__PropertyEditor ターゲットを持つ必要があります。

removableColorPicker.js-meta.xml

<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>66.0</apiVersion>
    <isExposed>true</isExposed>
    <masterLabel>Removable Color Picker</masterLabel>
    <targets>
        <target>lightning__PropertyEditor</target>
    </targets>
</LightningComponentBundle>

removableColorPicker.html

標準のカラー入力と、値をクリアするボタンを配置します。

<template>
    <div class="slds-form-element slds-m-bottom_x-small">
        <label class="slds-form-element__label">{label}</label>
        <div class="slds-form-element__control">
            <div class="slds-grid slds-gutters_x-small slds-grid_vertical-align-center">
                <div class="slds-col slds-grow-none">
                    <input 
                        type="color" 
                        class="slds-input slds-p-around_none"
                        style="height: 32px; width: 50px;"
                        value={pickerValue} 
                        onchange={handleChange}>
                </div>
                <div class="slds-col slds-grow">
                    <lightning-input 
                        type="text" 
                        variant="label-hidden"
                        value={pickerValue}
                        pattern="^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"
                        onchange={handleChange}
                        placeholder="#RRGGBB">
                    </lightning-input>
                </div>
                
                <template if:true={hasValue}>
                    <div class="slds-col slds-grow-none">
                        <lightning-button-icon 
                            icon-name="utility:undo" 
                            variant="border-filled" 
                            alternative-text="リセット" 
                            title="設定を解除"
                            onclick={handleClear}>
                        </lightning-button-icon>
                    </div>
                </template>
            </div>
            
            <template if:false={hasValue}>
                <div class="slds-form-element__help">未設定 (デフォルト適用)</div>
            </template>
        </div>
    </div>
</template>

removableColorPicker.js

エクスペリエンスビルダーとの通信には valuechange イベントを使用します。

import { LightningElement, api } from 'lwc';

export default class RemovableColorPicker extends LightningElement {
    @api label; 
    @api value;

    /**
     * 値が設定されているか(バッジ表示用)
     * null, undefined, 空文字以外なら true
     */
    get hasValue() {
        return this.value != null && this.value !== '';
    }

    /**
     * カラーピッカーに渡す値
     * 余計なデフォルト値を設定せず、nullならnullを渡します。
     * (lightning-input type="color" は null を受け取るとブラウザ標準の色を表示しますが、
     * hasValueがfalseなので「デフォルト」バッジが表示され、区別がつきます)
     */
    get pickerValue() {
        return this.value;
    }

    handleChange(event) {
        // 値が空文字の場合は null として扱う
        const val = event.target.value;
        this.dispatchChange(val === '' ? null : val);
    }

    handleClear() {
        this.dispatchChange(null);
    }

    dispatchChange(val) {
        // 標準仕様: 'valuechange' イベントで { value: newValue } を送る
        this.dispatchEvent(new CustomEvent('valuechange', {
            bubbles: true,
            composed: true,
            detail: { value: val }
        }));
    }
}

ステップ4:テーマ切り替え本体を作成する

最後に、サイト利用者が触れるスイッチ本体 c-dark-mode-switch-button を作成します。

darkModeSwitchButton.js-meta.xml

ここで、ステップ1で作成したカスタム型 c__themeSettingsType を指定します。

<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>66.0</apiVersion>
    <isExposed>true</isExposed>
    <masterLabel>Dark Mode Switch Button</masterLabel>
    <targets>
        <target>lightningCommunity__Page</target>
        <target>lightningCommunity__Default</target>
    </targets>
    <targetConfigs>
        <targetConfig targets="lightningCommunity__Default">
            <property
                name="themeSettings"
                type="c__themeSettingsType"
                label="テーマ設定"
            />
        </targetConfig>
    </targetConfigs>
</LightningComponentBundle>

darkModeSwitchButton.js

渡された themeSettings オブジェクトを読み取り、CSS変数を適用します。

import { LightningElement, api } from 'lwc';

export default class DarkModeSwitchButton extends LightningElement {
    // 内部保持用の変数
    _themeSettings;

    @api
    get themeSettings() {
        return this._themeSettings;
    }

    set themeSettings(value) {
        this._themeSettings = value;
        
        // Settingが更新された際、まだ初期化前(isDark=false)でも
        // 本来ダークモードであるべきなら適用してあげる必要がある
        if (!this.isDark) {
            this.initializeDarkMode();
        }

        // 値が変更されたタイミングでテーマを再適用
        if (this.isDark) {
            this.applyTheme();
        }
    }

    isDark = false;

    connectedCallback() {
        this.initializeDarkMode();
        if (this.isDark) {
            this.applyTheme();
        }
    }

    initializeDarkMode() {
        // localStorage または OS設定の確認
        const savedTheme = localStorage.getItem('siteTheme');
        if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
            this.isDark = true;
        }
    }

    handleToggleClick() {
        this.isDark = !this.isDark;
        this.applyTheme();
        localStorage.setItem('siteTheme', this.isDark ? 'dark' : 'light');
    }

    applyTheme() {
        const style = document.documentElement.style;
        // Getter経由で取得、なければ空オブジェクト
        let settings = this.themeSettings || {};
        
        if (typeof settings === 'string') {
            try {
                settings = JSON.parse(settings);
            } catch (e) {
                console.error('JSON Parse Error', e);
                settings = {};
            }
        }
        
        const darkVars = this.getCssVariableMap(settings);
        const darkKeys = Object.keys(darkVars);

        if (this.isDark) {
            // === ダークモード適用 ===
            darkKeys.forEach(key => {
                const val = darkVars[key];
                if (val) {
                    style.setProperty(key, val);
                } else {
                    style.removeProperty(key);
                }
            });
            this.injectOverrideStyles(settings);
        } else {
            // === ダークモード解除 ===
            darkKeys.forEach(key => {
                style.removeProperty(key);
            });
            
            // ライトモード変数の適用 (iconColorForegroundLightなど)
            const lightVars = this.getLightModeVars(settings);
            Object.keys(lightVars).forEach(key => {
                const val = lightVars[key];
                if (val) {
                    style.setProperty(key, val);
                } else {
                    style.removeProperty(key);
                }
            });

            // ライトモード変数の復元や強制スタイルの削除処理
            const overrideStyle = document.getElementById('dxforce-style-overrides');
            if (overrideStyle) overrideStyle.remove();
        }
    }

    getLightModeVars(s) {
        const val = s.iconColorForegroundLight;
        return {
            '--slds-c-icon-color-foreground': val,
            '--slds-c-icon-color-foreground-default': val,
            '--dxp-g-neutral-3': val
        };
    }

    // 設定オブジェクトのキーとCSS変数のマッピング
    getCssVariableMap(s) {
        return {
            // --- 基本 ---
            '--dxp-g-root': s.colorRoot,
            '--dxp-g-root-contrast': s.colorText,
            '--dxp-g-brand': s.colorBrand,
            '--dxp-g-brand-contrast': s.colorBrandContrast,
            '--slds-c-icon-color-foreground': s.iconColorForeground,
            '--slds-c-icon-color-foreground-default': s.iconColorForeground,
            '--dxp-g-neutral-3': s.iconColorForeground,

            // 以下省略(実装はGitHubをご覧ください)
        };
    }

    // CSS変数でカバーできない箇所の強制上書き
    injectOverrideStyles(s) {
        const existing = document.getElementById('dxforce-style-overrides');
        if (existing) existing.remove();

        const cssRules = [];
        if (s.formBg) {
            cssRules.push(`input { background-color: var(--dxp-s-form-element-color-background) !important; }`);
        }
        if (s.recListHeaderBg) {
            cssRules.push(`
                .record-list-table-wrapper, .forceListViewManagerHeader {
                    background-color: var(--dxp-c-record-list-column-header-background-color) !important;
                    --dxp-c-record-list-column-header-background-color: ${s.recListHeaderBg} !important;
                }
            `);
        }
        if (s.recListRowHoverColor) {
            cssRules.push(`
                tr:hover > td, tr:hover > th, .slds-table tbody tr:hover > td, .slds-table tbody tr:hover > th {
                    background-color: ${s.recListRowHoverColor} !important;
                    --dxp-c-record-list-table-row-hover-color: ${s.recListRowHoverColor} !important;
                }
            `);
        }
        if (s.colorRoot) {
            cssRules.push(`
                html, body {
                    background-color: var(--dxp-g-root) !important;
                }
                body { min-height: 100vh !important; }
            `);
        }

        if (cssRules.length > 0) {
            const styleTag = document.createElement('style');
            styleTag.id = 'dxforce-style-overrides';
            styleTag.textContent = cssRules.join('\n');
            document.head.appendChild(styleTag);
        }
    }
}

まとめ

ExperiencePropertyTypeBundleを採用することで、以下のようなメリットが得られました。

  1. UI構築の自動化: 最大のメリットはここにあります。アコーディオンやタブといった複雑なレイアウトを LWC の HTML/CSS で自作する必要がなくなり、design.json だけで完結できました。
  2. データ構造の明文化: 巨大な JSON 文字列をブラックボックスとして扱うのではなく、schema.json によって設定項目が定義されるため、メンテナンス性が向上しました。

lightning__objectType は便利ですが、Builder からの入力値が特定の状況下で文字列として渡されるケースに備え、LWC 側で JSON.parse をフェイルセーフとして実装に残す判断をしました。「完全にパース不要」という理想よりも、「どんな状況でも壊れない」という堅牢性を優先した結果、完成度の高いコンポーネントとなりました。

Spring ’26以降、LWRサイトでの高度なコンポーネント開発において、この手法は間違いなくスタンダードになります。ぜひあなたのプロジェクトでも導入してみてください。

参考URL

Salesforce Spring ’26 Release Notes

Custom Property Types and Property Editors

Metadata API Developer Guide: ExperiencePropertyTypeBundle

DXforceの管理人

福島 瑛二

2013年にJavaエンジニアとしてのキャリアをスタート。2019年にSalesforceと出会い、Salesforceエンジニアの道へ。

デザインや UI/UX の観点からもシステムを捉え、ユーザーにとって心地よい体験を実装することにやりがいを感じています。

CRM(顧客データ)や Data Cloud と連携した高度なサイトを目に見える形で表現できる Experience Cloud に大きな可能性を見出しており、バックエンドのデータ構造とフロントエンドの表現力を極めることがこれからの Salesforce エンジニアに求められるスキルだと確信しています。

Trailblazer: efukushima

福島 瑛二をフォローする

読者の声

タイトルとURLをコピーしました