Skip to content

ナビゲーションとテーマ ​

第13版作成 最終更新 (日本時間)
確認バージョン1.5.3.01.5.8.1

画面の移動や見た目にかかわるカスタマイズをまとめます。いずれも拡張サーバスクリプト・拡張スクリプト・拡張スタイル(一部は拡張 SQL・拡張 HTML を併用)で、本体を改修せずに全テーブルへ適用できます。

レシピ実装方法
パンくずリストに表示モード切替リンクを追加拡張サーバスクリプト + 拡張スタイル
拡張ナビゲーションメニューのアイコンを任意に設定(v2 テーマ)拡張スタイル
サイトメニューの列数・改行位置を制御拡張スタイル(+ 拡張スクリプト)
サイト画像とサイト種別アイコンの表示を両立拡張スタイル(+ 拡張スクリプト)
特権ユーザでログイン中であることを目立たせる拡張スタイル
サイトを開くときに確認ゲートを表示拡張スクリプト + 拡張スタイル(+ 拡張 SQL)
スクロール固定した列に目印を付けるサーバスクリプト / 拡張サーバスクリプト
画面右下にスクロールボタンを追加拡張スクリプト + 拡張スタイル
v2 テーマの配色モードとテーマを 1 つの FAB で切替(統合版)拡張スクリプト + 拡張スタイル
配色モード切替 FAB・テーマ切替 FAB(単機能版)拡張スクリプト + 拡張スタイル

パンくずリストに表示モード切替リンクを追加する ​

編集画面から戻る方法には、コマンドボタンの「戻る」(History Back なので元の表示モードに戻る)とパンくずリスト(常に一覧画面に戻る)があります。ナビゲーションメニューの「表示」からも任意の表示モードに移れますが、メニューを 2 段階開く必要があります。そこで、パンくずリストの末尾に表示モード(一覧・カレンダー・クロス集計・ガント・バーンダウン・時系列・分析・カンバン・画像ライブラリ)へのアイコンリンクを追加します。さらに編集画面では、直前に開いていた表示モードへ戻るリンクも追加します。

パンくずリストのサイト名の右に追加された表示モード切替アイコン

設置 ​

App_Data/Parameters/ExtendedServerScripts/ に設定ファイルと本文、App_Data/Parameters/ExtendedStyles/ にスタイルを置きます。

json
{
    "BeforeOpeningPage": true,
    "BeforeOpeningRow": true,
    "Body": "-- Write an arbitrary javascript."
}
js
(function() {
    var navItems = [{
        action: "Index",
        icon: "view_list"
    }, {
        action: "Calendar",
        icon: "calendar_month"
    }, {
        action: "Crosstab",
        icon: "table"
    }, {
        action: "Gantt",
        icon: "view_timeline"
    }, {
        action: "BurnDown",
        icon: "timeline"
    }, {
        action: "TimeSeries",
        icon: "area_chart"
    }, {
        action: "Analy",
        icon: "clock_loader_40"
    }, {
        action: "Kamban",
        icon: "pivot_table_chart"
    }, {
        action: "ImageLib",
        icon: "photo_library"
    }];
    
    if (context.Action === "edit" && context.UrlReferrer) {
        var protocol = "https";//実環境に合わせた情報を入力
        var host = "<Host Name or IP Address>";//実環境に合わせた情報を入力
        var rootPath = `${protocol}://${host}${context.ApplicationPath}`;
        if (context.UrlReferrer.startsWith(rootPath)) {
            var path = context.UrlReferrer.replace(rootPath, "").split("?")[0].split("/");
            if (path.length === 3) {
                if (path[0] === "items" && path[1] === `${context.SiteId}` && navItems.map((x) => x.action.toLowerCase()).includes(path[2])) {
                    var content = `<a href="${context.ApplicationPath}items/${context.SiteId}/${path[2]}" id="BreadcrumbMenu_HistoryBack"><span class="material-symbols-outlined">arrow_back</span></a>`;
                    var target = `:root:has(#Action[value="edit"]) #Breadcrumb:not(:has(#BreadcrumbMenu_HistoryBack)) .item:last-child`;
                    context.AddResponse('Append', target, content);
                }
            }
        }
    }

    navItems.forEach(navItem => {
        var content = `<a href="${context.ApplicationPath}items/${context.SiteId}/${navItem.action.toLowerCase()}" id="BreadcrumbMenu_${navItem.action}"><span class="material-symbols-outlined">${navItem.icon}</span></a>`;
        var target = `:root:not(:has(#Action[value="${navItem.action.toLowerCase()}"])):has(#ViewModelMenu_${navItem.action}) #Breadcrumb:not(:has(#BreadcrumbMenu_${navItem.action})) .item:last-child`;
        context.AddResponse('Append', target, content);
    });
}());
css
#Breadcrumb .item:last-child a:not(:first-child){
  padding:0 5px;
}

protocol と host は実環境に合わせて書き換えてください。

仕組み ​

  • 各リンクは context.AddResponse('Append', ...) でパンくずリストの最後の .item に追加します。
  • セレクタの :has(#ViewModelMenu_{表示モード}) で、ナビゲーションメニューに存在する(そのテーブルで有効な)表示モードだけを出します。
  • :not(:has(#Action[value="..."])) で、いま表示中の表示モードへのリンクは出しません。:not(:has(#BreadcrumbMenu_...)) は二重追加の防止です。
  • 編集画面では context.UrlReferrer(直前のページの URL)がプリザンターの items/{SiteId}/{表示モード} のときだけ、そこへ戻る arrow_back アイコンを追加します。外部サイトや編集画面など、表示モードを持たないページから来た場合は出ません。
  • アイコンは Google Material Symbols です。変えたいときは navItems の icon を書き換えます。

バリエーション ​

現在の表示モードのアイコンも常に表示したい場合(ナビゲーションメニューの挙動に合わせる場合)は、target を次のように変えます。

js
var target = `:root:has(#ViewModelMenu_${navItem.action}) #Breadcrumb:not(:has(#BreadcrumbMenu_${navItem.action})) .item:last-child`;

編集画面だけに表示したい場合は、設定ファイルに "Actions": ["edit"] を追加します。

json
{
    "Actions": ["edit"],
    "BeforeOpeningPage": true,
    "BeforeOpeningRow": true,
    "Body": "-- Write an arbitrary javascript."
}

拡張ナビゲーションメニューのアイコンを任意に設定する(v2 テーマ) ​

拡張ナビゲーションメニュー(ExtendedNavigationMenu)は、App_Data/Parameters/ExtendedNavigationMenus/ に JSON を置くか、Extensions テーブルに ExtensionType = 'NavigationMenu' のレコードを追加すると、サイドメニューに独自のメニューを追加できる拡張機能です。定義の Icon プロパティに CSS クラス名を指定すると v1 テーマではアイコンが表示されますが、v2 テーマでは同じ設定のままでも、カスタムメニューのアイコンがすべて共通の icon-menu-custom.svg(歯車のような汎用アイコン)になります。拡張スタイルだけで、コンテナごとに任意のアイコンへ差し替えられます。

INFO

バージョン 1.5.3.0 を対象にしています。

v2 テーマでアイコンが固定される理由 ​

NavigationMenu クラスには ContainerId・MenuId・Id・Css・Icon・Url・Function・LinkParams・Target・ReferenceTypes・ChildMenus があります(NavigationMenu.cs)。

レンダリングは HtmlNavigationMenu.cs で、テーマのバージョンによって処理が分かれます(HtmlNavigationMenu.cs)。

  • v2 テーマ(context.ThemeVersionOver2_0())では、switch (menu.ContainerId) で iconName が固定値で代入されます。標準の NewMenuContainer・ViewModeMenuContainer・SettingsMenuContainer・HelpMenuContainer・AccountMenuContainer には専用の SVG が割り当てられ、それ以外(カスタムで追加したコンテナ)は default 節ですべて icon-menu-custom.svg になります。
  • menu.Icon を出力する Span には _using: context.ThemeVersion1_0() が付いているため、v2 テーマではアイコン用の span 自体が出力されません(HtmlNavigationMenu.cs)。つまり Icon の値は v2 テーマでは無視されます。

図を読み込み中…

v2 テーマでカスタムコンテナから生成される HTML は次の構造です(HtmlNavigationMenu.cs)。

html
<div class="menubox" id="MyContainer">
  <input type="checkbox" id="block-MyContainer" class="toggle">
  <label class="menulabel" for="block-MyContainer">
    <span onclick="$p.expandSideMenu($(this));">
      <img src="/images/icon-menu-custom.svg">
    </span>
    <span>表示テキスト</span>
  </label>
  <div class="menubox-sub">
    <ul>...</ul>
  </div>
</div>
  • 外枠の div.menubox の id には ContainerId がそのまま入ります。
  • アイコンは label.menulabel > span > img として出力されます。

このため、コンテナ ID をセレクタに使えば、特定のカスタムメニューのアイコンだけを CSS で差し替えられます。

設定 ​

差し替え対象の拡張ナビゲーションメニューは、ContainerId を一意な値にしておきます。

json
{
  "TargetId": "SettingsMenu",
  "Action": "Append",
  "NavigationMenus": [
    {
      "ContainerId": "MyToolsContainer",
      "MenuId": "MyToolsMenu",
      "Name": "ツール",
      "ChildMenus": [
        {
          "MenuId": "MyToolsMenu_Calculator",
          "Name": "電卓",
          "Url": "/items/123/index",
          "Icon": "ui-icon-calculator"
        },
        {
          "MenuId": "MyToolsMenu_Reports",
          "Name": "レポート",
          "Url": "/items/456/index",
          "Icon": "ui-icon-document"
        }
      ]
    }
  ]
}

Append は TargetId と MenuId が一致するメニューを探し、その後ろに NavigationMenus を挿入します。一致するメニューが無ければ何も追加されないので、TargetId は省略できません(HtmlNavigationMenu.cs#L1095-L1115)。この例では「管理」メニュー(SettingsMenu)の後ろに追加します。子メニューの表示テキストは Name から作られ、Name が無いと文字の無いリンクになります(HtmlNavigationMenu.cs#L575-L611)。

ChildMenus の Icon は v1 テーマでだけ使われます。v1 テーマと併用する環境向けに残しておき、v2 テーマでは次の拡張スタイルで差し替えます。拡張ナビゲーションメニューの基本的な使い方や Action(Append / Prepend / Replace / Remove)は公式マニュアルを参照してください。

App_Data/Parameters/ExtendedStyles/ に CSS を置き、img 要素の描画を content プロパティで別の画像に置き換えます。content: url(...) を指定すると、ブラウザは元の src を無視して指定した画像を描画します。

css
/* 「ツール」メニューのアイコンを差し替える */
#MyToolsContainer .menulabel img {
    content: url("/images/my-tools-icon.svg");
}

/* 別のカスタムコンテナを追加した場合も同様にコンテナ ID で個別指定する */
#ReportsContainer .menulabel img {
    content: url("/images/reports-icon.svg");
}

v2 テーマのナビゲーションメニューで「ツール」だけアイコンを差し替えた様子(特権管理・Operations Tools は既定の固定アイコン)

アイコン画像の置き場所 ​

配置場所利用例備考
プリザンターの wwwroot/images/ 配下/images/my-tools-icon.svgサーバ側のファイル配置が必要。デプロイ時に追加するので本体ファイルとの混在に注意
外部 CDN(jsDelivr 等)https://cdn.jsdelivr.net/.../icon.svgサーバ側の配置は不要。社外ネットワークへ通信する点と利用規約に注意

追加ファイルを置きたくない場合は、data: URL で CSS に埋め込めば CSS 1 ファイルで完結します。

css
#MyToolsContainer .menulabel img {
    content: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'><path d='M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z'/></svg>");
}

サイズと色味の調整 ​

大きさは width / height、色味は filter(invert(...)・hue-rotate(...) など)で調整します。

css
#MyToolsContainer .menulabel img {
    content: url("/images/my-tools-icon.svg");
    width: 24px;
    height: 24px;
    /* テーマの暗色側に寄せる場合の例 */
    filter: invert(0.2);
}

var(--base-text) のような CSS 変数とは併用しづらいため、テーマごとに細かく追従させたいときは、テーマごとにファイルを分けるか、複数の SVG を用意する運用が無難です。

子メニューにアイコンを付ける ​

ChildMenus の各項目(li 要素)には、v2 テーマではアイコン用のタグが出力されません。Id を手がかりに、擬似要素 ::before で画像を挿入します。

css
#MyToolsContainer #MyTools_Calculator > a::before {
    content: url("/images/calculator-icon.svg");
    display: inline-block;
    width: 16px;
    height: 16px;
    margin-right: 8px;
    vertical-align: middle;
}

#MyToolsContainer #MyTools_Reports > a::before {
    content: url("/images/reports-icon.svg");
    display: inline-block;
    width: 16px;
    height: 16px;
    margin-right: 8px;
    vertical-align: middle;
}

子メニューはレイアウト(padding: 8px 8px 8px 52px など)が決まっているので、width / height / margin も指定してテキストとの位置関係を整えます。

v1 テーマとの両立 ​

設定v1 テーマv2 テーマ
NavigationMenu.Icon(CSS クラス)反映される無視される
拡張スタイル(#ContainerId img)影響しない反映される

両方を設定しておけば、テーマを切り替えてもアイコンが抜けません。

サイトメニューの列数と改行位置を制御する ​

フォルダを開いたときのサイトメニュー(テーブルやフォルダのパネル一覧)は、ウィンドウ幅に応じて自動で折り返されるため、グループごとにまとめたり列数を固定したりはできません。拡張スタイル(必要に応じて拡張スクリプト)でレイアウトを制御します。

サイトメニューの構造 ​

html
<ul class="nav-sites sortable ui-sortable">
    <li class="nav-site sites has-image ui-sortable-handle" data-value="12345" data-type="Sites">
        <a href="/items/12345/index">
            <div class="site-icon">
                <img alt="image" src="/items/12345/binaries/siteimagethumbnail/...">
            </div>
            <span class="title">営業管理</span>
            <div class="heading"></div>
            <div class="conditions">
                <span class="elapsed-time">1 時間前</span>
                <span class="reference material-symbols-outlined">folder</span>
                <span class="count">100</span>
            </div>
        </a>
    </li>
    <li class="nav-site results ui-sortable-handle" data-value="12346" data-type="Results">
        <!-- 同様の構造 -->
    </li>
    <!-- 以下、サイトの数だけ繰り返し -->
</ul>

v2 テーマ(cerulean 等)では .nav-sites に display: flex; flex-wrap: wrap; が適用され、各 .nav-site は幅 209px のパネルです。各パネルの data-value 属性にはサイト ID が入っています。

INFO

v1 テーマ(sunny 等)ではパネル幅が 220px です。以下の例の 209px は利用中のテーマに合わせて読み替えてください。

列数を固定する ​

.nav-sites を CSS Grid に変えます。App_Data/Parameters/ExtendedStyles/ に置きます。

css
.nav-sites {
    display: grid !important;
    grid-template-columns: repeat(3, 209px);
    gap: 24px 16px;
}

repeat(3, 209px) の 3 が列数です(2 なら 2 列固定、4 なら 4 列固定)。

サイトメニューを 3 列に固定した様子

最大列数だけを制限し、ウィンドウが狭いときは列を減らしたい場合は、auto-fill と max-width を組み合わせます。次の例は最大 4 列です(max-width の計算式の 4 で調整)。

css
.nav-sites {
    display: grid !important;
    grid-template-columns: repeat(auto-fill, 209px);
    max-width: calc(209px * 4 + 16px * 3);
    gap: 24px 16px;
}

任意の位置で改行する ​

サイト ID を data-value で指定して改行位置を決めます。方法は 3 通りあります。

方法使うもの特徴
grid-column: -1拡張スタイルCSS だけで済むが、対象がもともと行末にない場合は途中の列が空欄になる
スペーサー要素を挿入拡張スタイル + 拡張スクリプト空欄を作らずに改行できる
次のパネルに grid-column-start: 1拡張スタイル + 拡張スクリプト列数固定と改行を両立できる

grid-column: -1(CSS のみ) ​

css
.nav-sites {
    display: grid !important;
    grid-template-columns: repeat(3, 209px);
    gap: 24px 16px;
}

/* サイトID: 12345 の後ろで改行 */
.nav-site[data-value="12345"] {
    grid-column: -1;
}

grid-column: -1 は「そのサイトを行の最後の列に置く」指定なので、後続のサイトは次の行に回ります。対象のサイトがもともと行末にないと、途中の列が空欄になります。

スペーサー要素を挿入する ​

指定したサイトの直後に幅 100% の不可視要素を挿入し、後続のサイトを必ず次の行へ送ります。

css
.nav-sites {
    display: flex;
    flex-wrap: wrap;
    gap: 24px 16px;
}

/* スペーサーで改行を強制 */
.nav-site-break {
    flex-basis: 100%;
    height: 0;
}
js
// サイトID: 12345 の後ろに改行用スペーサーを挿入
$(function () {
    if ($('.nav-sites').length) {
        $('.nav-site[data-value="12345"]').after(
            '<li class="nav-site-break"></li>'
        );
    }
});

列数固定と改行を組み合わせる ​

CSS セレクタだけでも書けますが、条件が複雑になり可読性が下がるため、拡張スクリプトで改行したいサイトの次のパネルに .row-start クラスを付け、grid-column-start: 1 で行頭に置く方法が勧められています。

css
.nav-sites {
    display: grid !important;
    grid-template-columns: repeat(4, 209px);
    gap: 24px 16px;
}

/* 改行対象のサイトの次を行の先頭に配置 */
.nav-site.row-start {
    grid-column-start: 1;
}
js
// サイトID: 12345 の次のサイトを行の先頭に配置
$(function () {
    if ($('.nav-sites').length) {
        $('.nav-site[data-value="12345"]')
            .next('.nav-site')
            .addClass('row-start');
    }
});

サイト画像とサイト種別アイコンの表示を両立する 1.4.12.0 以降 ​

バージョン 1.4.12.0(2025/1/14 リリース)から、サイトメニューのパネルにサイト種別を識別できるアイコンが表示されるようになりました。cerulean などの新しい UI では、sunny などの従来のテーマのようにはサイト種別が分からなくなっていたため、それに対応したものです。サイト画像を設定しているサイトでも種別アイコンが表示されるので、どの種別のサイトかを一目で区別できます。

ただし、種別アイコンは件数表示の左側に表示されるため、件数の桁数によってサイトごとにアイコンの位置がばらばらになります。これを拡張スタイル(別解 2 は拡張スクリプトも併用)で解消する方法を 3 つ紹介します。

方法使うもの表示
別解 1:背景に表示する拡張スタイル種別アイコンをパネルの左下に背景画像として表示
別解 2:件数の右側に表示する拡張スタイル + 拡張スクリプト種別アイコンを件数表示の後ろに移動
別解 3:表示しない拡張スタイル種別アイコンを非表示

サイトメニューのパネルの構造 ​

html
<li class="nav-site sites has-image ui-sortable-handle" data-value="2202349" data-type="Sites">
    <a href="/items/2202349/index">
        <div class="site-icon">
            <img alt="image" src="/items/2202349/binaries/siteimagethumbnail/?20230814071747">
        </div>
        <span class="title">INNOVERA</span>
        <div class="heading"></div>
        <div class="conditions">
            <span class="elapsed-time" title="更新日時 2025/01/21 9:09:24">44 分前</span>
            <span class="reference material-symbols-outlined" title="フォルダ">folder</span>
            <span class="count" title="件数">20431</span>
        </div>
    </a>
</li>

.conditions の中の span.reference(Material Symbols のアイコン名がテキストで入る)が種別アイコンです。

v2 テーマでは、サイト画像の有無でパネルの中身が変わります(SiteUtilities.cs)。

パネルdiv.site-icon の imgspan.reference
サイト画像あり(li に has-image クラス)/items/{サイトID}/binaries/siteimagethumbnail/...出力される
サイト画像なし/images/icon-site-sites.svg など種別ごとの画像(icon-site-results.svg・icon-site-issues.svg・icon-site-wikis.svg・icon-site-dashboards.svg)出力されない

span.reference は _using: hasImage の条件付きで、サイト画像を設定したパネルにだけ出力されます(SiteUtilities.cs)。サイト画像がないパネルは、パネル中央の画像そのものが種別アイコンになっています。画像のパスは Locations.Get でアプリケーションパスを付けて小文字化したもの(/images/icon-site-*.svg)です。

プリザンターは HTML をサーバ側で一括して描画しており、テンプレートで差し替える仕組みはないため、拡張スタイルと拡張スクリプトで変更します。

別解 1:種別アイコンを背景に表示する ​

外枠の li.nav-site に、サイト種別のクラス(sites・results・issues・wikis・dashboards)ごとに種別アイコンの画像を背景として設定し、もとの span.reference は非表示にします。

css
.conditions .reference {
    display: none !important;
}

/* サブディレクトリで運用している場合は src と url の書き換えが必要 */
.nav-site:not(.to-parent):not(:has(img[src^='/images/icon-site-'])) {
    background-position: left 0.5em bottom 0.5em;
    background-repeat: no-repeat;
    background-size: 2em;
    
    .title {
        padding-left: 2em;
        padding-right: 2em;
    }

    &.sites {
        background-image: url("/images/icon-site-sites.svg");
    }

    &.results {
        background-image: url("/images/icon-site-results.svg");
    }

    &.issues {
        background-image: url("/images/icon-site-issues.svg");
    }

    &.wikis {
        background-image: url("/images/icon-site-wikis.svg");
    }

    &.dashboards {
        background-image: url("/images/icon-site-dashboards.svg");
    }
}
  • アイコンはパネルの左下(left 0.5em bottom 0.5em)に 2em の大きさで表示し、タイトルの左右に 2em の余白を取ってアイコンと重ならないようにしています。
  • .to-parent(親フォルダへ戻るパネル)は対象外です。
  • スマートフォンのレスポンシブ表示でも、同じようにまとまった表示になります。
  • プリザンターをサブディレクトリで運用している場合は、セレクタの src と url() のパスを書き換えてください。

セレクタの :not(:has(img[src^='/images/icon-site-'])) は「標準の種別アイコン画像(/images/icon-site-*.svg)を表示していないパネル」、つまりサイト画像を設定したパネル(.nav-site.has-image と同じ対象)だけに背景アイコンを付ける条件です。サイト画像がないパネルはもともと種別アイコンの画像を表示しているので、対象外にしています。すべてのサイトに表示したい場合は次のように条件を外しますが、サイト画像がないパネルでは種別アイコンが 2 つ(中央の画像と左下の背景)表示されます。

diff
- .nav-site:not(.to-parent):not(:has(img[src^='/images/icon-site-'])) {
+ .nav-site:not(.to-parent) {

別解 2:種別アイコンを件数の右側に表示する ​

位置がそろわないのは件数表示の左側にあるためなので、件数表示(span.count)の後ろに表示します。要素を探して入れ替える代わりに、既存の span.reference を拡張スタイルで非表示にし、拡張スクリプトで div.conditions の末尾に append クラス付きの span.reference を追加します。

css
.conditions .reference:not(.append) {
    display: none !important;
}
js
//サブディレクトリで運用している場合はsrcの書き換えが必要
$('.nav-site.sites a:not(:has(img[src^="/images/icon-site-"])) .conditions:not(:has(.reference.append))').append('<span class="reference append material-symbols-outlined">folder</span>');
$('.nav-site.results a:not(:has(img[src^="/images/icon-site-"])) .conditions:not(:has(.reference.append))').append('<span class="reference append material-symbols-outlined">table</span>');
$('.nav-site.issues a:not(:has(img[src^="/images/icon-site-"])) .conditions:not(:has(.reference.append))').append('<span class="reference append material-symbols-outlined">view_timeline</span>');
$('.nav-site.wikis a:not(:has(img[src^="/images/icon-site-"])) .conditions:not(:has(.reference.append))').append('<span class="reference append material-symbols-outlined">text_snippet</span>');
$('.nav-site.dashboards:not(:has(img[src^="/images/icon-site-"])) a .conditions:not(:has(.reference.append))').append('<span class="reference append material-symbols-outlined">dashboard</span>');

サイト種別ごとに追加するアイコン(Material Symbols のアイコン名)は次のとおりです。

サイト種別(クラス)アイコン名
フォルダ(sites)folder
記録テーブル(results)table
期限付きテーブル(issues)view_timeline
Wiki(wikis)text_snippet
ダッシュボード(dashboards)dashboard
  • .conditions:not(:has(.reference.append)) で、追加済みのパネルに二重に追加しないようにしています。
  • プリザンターをサブディレクトリで運用している場合は、セレクタの src のパスを書き換えてください。

上のスクリプトも img[src^="/images/icon-site-"] の条件で、サイト画像を設定したパネル(もともと span.reference があるパネル)だけに追加しています。サイト画像がないパネルにも件数の右側へ種別アイコンを付けてそろえたい場合は、次のスクリプトに置き換えます(そのパネルでは中央の画像と合わせて種別アイコンが 2 つ表示されます)。

js
$('.nav-site.sites a .conditions:not(:has(.reference.append))').append('<span class="reference append material-symbols-outlined">folder</span>');
$('.nav-site.results a .conditions:not(:has(.reference.append))').append('<span class="reference append material-symbols-outlined">table</span>');
$('.nav-site.issues a .conditions:not(:has(.reference.append))').append('<span class="reference append material-symbols-outlined">view_timeline</span>');
$('.nav-site.wikis a .conditions:not(:has(.reference.append))').append('<span class="reference append material-symbols-outlined">text_snippet</span>');
$('.nav-site.dashboards a .conditions:not(:has(.reference.append))').append('<span class="reference append material-symbols-outlined">dashboard</span>');

別解 3:種別アイコンを表示しない ​

種別アイコンが不要な場合は、次の拡張スタイルだけで非表示にできます。

css
.conditions .reference {
    display: none !important;
}

特権ユーザでログイン中であることを目立たせる ​

特権ユーザ(サイトやレコードのアクセス権限を無視して最上位の権限を持つユーザ)でログインしても、画面上では見分けが付きません。特権ユーザかどうかを直接取得する方法がないため、ユーザ ID で判定して背景色を変えます。

body の先頭には <input id="UserId" name="UserId" type="hidden" value="1"> のような hidden input があるので、CSS の :has() で判定できます。

css
body:has(#UserId[value="1"]) {
    background: red;
}
  • ユーザ ID 1 の部分は環境に合わせて読み替えてください。特権ユーザが複数いる場合はセレクタをカンマで連結します。
  • 同じ方法で、特定の組織に所属するアカウントの背景色を変えるなどの応用もできます。

UserId 1 でログイン中に背景が赤になったサイト一覧

サイトを開くときに確認ゲートを表示する ​

個人情報を含むテーブルを開く前に取り扱い注意の確認を求めたい、特定の業務画面に入る前に特定フレーズを入力させたい、といった場合に、ページを開いた直後に全画面のオーバーレイ(アクセスゲート)を表示して確認を挟みます。拡張スクリプトと拡張スタイル(特定フレーズゲートでは拡張 SQL も)だけで実現でき、本体の修正は不要です。

機能動作
注意事項確認ゲート注意事項と「上記の内容を理解しました」チェックボックスを表示。チェックを入れると OK ボタンが有効になり、押すとオーバーレイが消えて操作できるようになる
特定フレーズゲート特定フレーズの入力欄を表示。正しいフレーズを入力して OK を押すと解除。誤っているとエラーメッセージを表示
スキップ一度確認を通過したら、一定時間はゲートを表示しない

サイトを開いた直後に表示されるアクセス確認ゲート(注意事項確認)

厳密なセキュリティ対策ではありません

これはユーザー操作に対する「確認ステップ」です。特定フレーズはサーバー側(拡張 SQL)で照合するのでクライアントに公開されませんが、JavaScript の実行を無効にするなどの手段でゲート自体は回避できます。機密情報へのアクセス制御には、プリザンターのアクセス権限機能を使ってください。

サーバー側で通過状態を持ち、未通過なら中身を出さない本体の機能として作る場合の設計は ページゲートの標準機能化 にまとめています。

図を読み込み中…

設置 ​

ファイル配置先必要な場合
AccessGate.cssApp_Data/Parameters/ExtendedStyles/常に
AccessGate.jsApp_Data/Parameters/ExtendedScripts/常に
VerifyAccessGate.json / VerifyAccessGate.json.sqlApp_Data/Parameters/ExtendedSqls/特定フレーズゲートを使う場合

配置後にプリザンターを再起動します(IIS リセットまたはアプリケーションプールのリサイクル)。拡張機能は Extensions テーブルで DB 管理することもできます。

拡張 SQL(特定フレーズゲート用) ​

特定フレーズはサーバー側の拡張 SQL で照合するため、ブラウザの開発者ツールから確認することはできません。opensesame を実際のフレーズに変えてください。

json
{
    "Name": "VerifyAccessGate",
    "Description": "アクセスゲートの特定フレーズ照合用",
    "Api": true,
    "CommandText": "-- see VerifyAccessGate.json.sql"
}

VerifyAccessGate.json.sql には、使っている DBMS に合わせて次のどれかを書きます。列名 Result は拡張スクリプトが読むので、大文字小文字をこのままにします(PostgreSQL は引用符で囲まないと小文字の result になります)。

sql
SELECT
    CASE WHEN @InputValue = 'opensesame'
        THEN 'OK'
        ELSE 'NG'
    END AS [Result]
sql
SELECT
    CASE WHEN @InputValue = 'opensesame'
        THEN 'OK'
        ELSE 'NG'
    END AS "Result"
sql
SELECT
    CASE WHEN @InputValue = 'opensesame'
        THEN 'OK'
        ELSE 'NG'
    END AS `Result`

@InputValue には、ユーザーが入力したフレーズが拡張スクリプトから Params で渡されます。/api/extended/sql が SQL パラメータとしてバインドするのはリクエストの Params の中身だけなので、InputValue をリクエストの直下に置くと渡りません(ExtendedApi.cs、ExtensionUtilities.cs#L33-L47、#L113-L117)。SQL パラメータとして処理されるので SQL インジェクションの心配はありません。

拡張スクリプト ​

冒頭の config でゲートの種別やメッセージを設定します。特定フレーズゲートでは $p.apiExec で api/extended/sql に Name と Params.InputValue を POST し、Response.Data.Table[0].Result が OK なら解除します。レスポンスの Data は結果セット名(Table)をキーにしたオブジェクトです(ExtensionUtilities.cs#L132-L152)。$p.apiExec は画面の Token を送信データに加えるので、Security.json の TokenCheck を有効にした環境でも通ります(_api.js、CheckApiContextAttributes.cs)。

ExtendedScripts/AccessGate.js(全文)
js
$(function () {
  // ─── 設定 ───────────────────────────────────────────
  var config = {
    // ゲート種別: 'notice'(注意事項確認) または 'passphrase'(特定フレーズ入力)
    gateType: 'notice',

    // 注意事項ゲートで表示するメッセージ
    noticeMessage:
      'この画面には機密情報が含まれています。\n' +
      '取り扱いには十分ご注意ください。',

    // 特定フレーズ照合用の拡張 SQL 名
    sqlName: 'VerifyAccessGate',

    // 特定フレーズ入力欄の上に表示するヒント
    passphraseHint: '特定フレーズを入力してください',

    // スキップ有効時間(時間単位)。0 でスキップ無効
    skipHours: 4,

    // localStorage で使用するキーのプレフィックス
    storageKey: 'AccessGate'
  };
  // ───────────────────────────────────────────────────

  // メインフォームが無いページ(ログイン画面等)では動作しない
  if (!$('#MainForm').length) return;

  // スキップ判定
  var fullKey = config.storageKey + '_' + location.pathname;

  function isSkipValid() {
    if (config.skipHours <= 0) return false;
    try {
      var data = JSON.parse(localStorage.getItem(fullKey));
      return data && data.exp && new Date().getTime() < data.exp;
    } catch (e) {
      return false;
    }
  }

  function saveSkip() {
    if (config.skipHours <= 0) return;
    var exp = new Date().getTime() + config.skipHours * 3600000;
    localStorage.setItem(fullKey, JSON.stringify({ exp: exp }));
  }

  if (isSkipValid()) return;

  // ── ゲート UI 構築 ──
  var $overlay = $('<div id="access-gate"></div>');
  var $dialog = $('<div class="ag-dialog"></div>');
  var $title = $(
    '<div class="ag-title">' +
      '<span class="material-symbols-outlined">lock</span>' +
      '<span>アクセス確認</span>' +
    '</div>'
  );
  var $content = $('<div class="ag-content"></div>');
  var $actions = $('<div class="ag-actions"></div>');
  var $btn = $('<button class="ag-submit" disabled>OK</button>');

  if (config.gateType === 'notice') {
    // ── 注意事項ゲート ──
    $content.append(
      $('<p class="ag-message"></p>').text(config.noticeMessage),
      $(
        '<label class="ag-check-label">' +
          '<input type="checkbox" id="ag-check" />' +
          '<span>上記の内容を理解しました</span>' +
        '</label>'
      )
    );
    $content.find('#ag-check').on('change', function () {
      $btn.prop('disabled', !this.checked);
    });
  } else {
    // ── 特定フレーズゲート ──
    $content.append(
      $('<p class="ag-hint"></p>').text(config.passphraseHint),
      $(
        '<input type="password" id="ag-input" ' +
          'class="ag-input" placeholder="特定フレーズ" />'
      ),
      $('<p class="ag-error"></p>')
    );
    $content
      .find('#ag-input')
      .on('input', function () {
        $btn.prop('disabled', !$(this).val());
        $content.find('.ag-error').text('');
      })
      .on('keydown', function (e) {
        if (e.key === 'Enter' && $(this).val()) $btn.trigger('click');
      });
  }

  $actions.append($btn);
  $dialog.append($title, $content, $actions);
  $overlay.append($dialog);
  $('body').append($overlay);

  // ── 送信処理 ──
  $btn.on('click', function () {
    if (config.gateType === 'passphrase') {
      $btn.prop('disabled', true).text('確認中...');
      $p.apiExec($('#ApplicationPath').val() + 'api/extended/sql', {
        data: {
          Name: config.sqlName,
          Params: { InputValue: $('#ag-input').val() }
        },
        done: function (data) {
          var t = data && data.Response && data.Response.Data
            && data.Response.Data.Table;
          if (t && t.length && t[0].Result === 'OK') {
            saveSkip();
            $overlay.fadeOut(300, function () {
              $overlay.remove();
            });
          } else {
            $('.ag-error').text('特定フレーズが正しくありません');
            $btn.prop('disabled', false).text('OK');
          }
        },
        fail: function () {
          $('.ag-error').text('検証に失敗しました');
          $btn.prop('disabled', false).text('OK');
        }
      });
      return;
    }
    saveSkip();
    $overlay.fadeOut(300, function () {
      $overlay.remove();
    });
  });
});
config の項目型既定値説明
gateTypestring'notice''notice' で注意事項確認、'passphrase' で特定フレーズ入力
noticeMessagestring機密情報への注意文注意事項ゲートに表示するメッセージ。\n で改行
sqlNamestring'VerifyAccessGate'特定フレーズ照合用の拡張 SQL 名
passphraseHintstring'特定フレーズを入力してください'入力欄の上に表示するヒント
skipHoursnumber4スキップ有効時間(時間)。0 でスキップ無効
storageKeystring'AccessGate'localStorage のキーのプレフィックス

拡張スクリプトは既定ですべてのページに適用されます。特定のサイトだけに適用するには、config の直後に条件を足します。

js
// サイト ID 12345 のみに適用する例
if (!location.pathname.match(/\/items\/12345/)) return;
js
// サイト ID 12345 または 67890 に適用する例
if (!location.pathname.match(/\/items\/(12345|67890)/)) return;

拡張スタイル ​

body:has(#access-gate) でゲート表示中の背面スクロールを止め、#access-gate を position: fixed; inset: 0; z-index: 99999 の半透明オーバーレイにして、中央に .ag-dialog を表示します。

ExtendedStyles/AccessGate.css(全文)
css
/* ゲート表示中は背面のスクロールを抑止 */
body:has(#access-gate) {
  overflow: hidden;
}

/* オーバーレイ(全画面) */
#access-gate {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.6);
  z-index: 99999;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* ダイアログ */
.ag-dialog {
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
  max-width: 480px;
  width: 90%;
  padding: 32px;
}

/* タイトル */
.ag-title {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 20px;
  font-weight: bold;
  margin-bottom: 24px;
  color: #333;
}

.ag-title .material-symbols-outlined {
  font-size: 28px;
  color: #e67700;
}

/* 注意事項メッセージ */
.ag-message {
  white-space: pre-wrap;
  line-height: 1.8;
  color: #444;
  background: #fff8e1;
  border-left: 4px solid #ffc107;
  padding: 16px;
  border-radius: 4px;
  margin-bottom: 20px;
}

/* チェックボックスラベル */
.ag-check-label {
  display: flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
  font-size: 14px;
}

.ag-check-label input[type='checkbox'] {
  width: 18px;
  height: 18px;
  cursor: pointer;
}

/* 特定フレーズヒント */
.ag-hint {
  color: #444;
  margin-bottom: 12px;
}

/* 特定フレーズ入力欄 */
.ag-input {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid #ccc;
  border-radius: 4px;
  font-size: 14px;
  box-sizing: border-box;
}

.ag-input:focus {
  outline: none;
  border-color: #1976d2;
  box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.2);
}

/* エラーメッセージ */
.ag-error {
  color: #d32f2f;
  font-size: 13px;
  min-height: 20px;
  margin-top: 8px;
}

/* ボタン領域 */
.ag-actions {
  margin-top: 24px;
  text-align: right;
}

/* OK ボタン */
.ag-submit {
  background: #1976d2;
  color: #fff;
  border: none;
  border-radius: 4px;
  padding: 10px 32px;
  font-size: 14px;
  cursor: pointer;
  transition: background 0.2s;
}

.ag-submit:hover:not(:disabled) {
  background: #1565c0;
}

.ag-submit:disabled {
  background: #bdbdbd;
  cursor: not-allowed;
}

スキップの仕組み ​

  1. 確認が完了すると、現在時刻に skipHours を足した有効期限を localStorage に保存します。
  2. 次にページを開いたとき、保存された期限が現在時刻より未来ならゲートを出しません。
  3. 期限が過ぎていれば再びゲートを表示します。

保存キーには location.pathname を含めているので、ページごとにスキップ状態が管理されます。localStorage はブラウザとドメイン単位で保持されるため、別のブラウザや端末では改めて確認が必要です。

ブラウザをまたいで確認状態を共有したい場合は、localStorage の代わりにプリザンターのセッション API(セッション間のデータ共有)でサーバー側にユーザー単位で保持する方法もあります。サブディレクトリに配置している環境ではエンドポイントのパスが変わるので、$('#ApplicationPath').val() + 'api/sessions' のように組み立てます。拡張スクリプトはログイン済みユーザーのブラウザセッション(Cookie 認証)で動くため、拡張 SQL の呼び出しもセッション API も API キーは不要です。

スクロール固定した列に目印を付ける 1.4.20.0 以降 ​

Ver.1.4.20.0 で追加された一覧の項目のスクロール固定は、Excel のウインドウ枠固定のように左側の列をまとめて固定するのではなく項目単位の固定なので、どの項目が固定されているか分かりにくいことがあります。固定された項目の見出しには data-cell-sticky="1" 属性が付くので、ここに Material Symbols の keep アイコンを差し込みます。

サーバスクリプトの場合は、条件「画面表示の前」で次を登録します。

js
if (['index', 'gridrows', 'newongrid', 'copyrow'].includes(context.Action))
{
    context.AddResponse(
        'Prepend', 
        `th[data-cell-sticky="1"] div:not(:has(.view-pin-icon))`,
        '<span class="view-pin-icon material-symbols-outlined" style="font-size: 1.5em;">keep</span>'
    );
}

全サイトで動かすなら拡張サーバスクリプトにします。対象アクションは設定ファイルの Actions で指定します。

json
{
    "Actions": ["index", "gridrows", "newongrid", "copyrow"],
    "BeforeOpeningPage": true,
    "Body": "-- Write an arbitrary javascript."
}
js
context.AddResponse(
    'Prepend', 
    `th[data-cell-sticky="1"] div:not(:has(.view-pin-icon))`,
    '<span class="view-pin-icon material-symbols-outlined" style="font-size: 1.5em;">keep</span>'
);

gridrows(スクロールでの追加読み込み)・newongrid・copyrow も対象にしておくことで、一覧の再描画後もアイコンが付きます。:not(:has(.view-pin-icon)) は二重追加の防止です。

画面右下にスクロールボタンを追加する ​

レコードの多い一覧画面や項目の多い編集画面で、先頭・末尾にすばやく移動できるボタンを画面右下に固定表示します。一覧画面では上下左右の十字パッド、それ以外の画面では上下の 2 ボタンになります。

画面右下に追加された上下左右のスクロールボタン

条件動作
$p.controller() が items 以外何もしない(管理画面等)
$p.action() が indexShadow DOM 内の .app-grid-frame と window をスクロール。上下左右 4 方向
その他(edit・new・kamban 等)window のみスクロール。上下 2 方向

一覧画面のスクロール対象 ​

一覧画面の <grid-container> はカスタム要素(Web Components)で、data-scrollable 属性があると Shadow DOM(mode: 'open')を使います。

text
<grid-container data-scrollable="1">
  #shadow-root (open)
    ├─ .app-grid-inner
    │   ├─ .app-grid-frame   ← 実際のスクロールコンテナ(overflow: auto)
    │   │   └─ <slot>         ← Light DOM の .grid テーブルが投影
    │   └─ .app-scroll-layer

<grid-container> 自体には overflow がなく、scrollTo() を呼んでも効きません。shadowRoot.querySelector('.app-grid-frame') で実際のスクロールコンテナを取得します。また、DOMContentLoaded の時点ではカスタム要素のアップグレードが終わっておらず shadowRoot が null のことがあるため、customElements.whenDefined('grid-container') で待ちます。

拡張スクリプト ​

js
$(function () {
  // items コントローラーのみ対象
  if ($p.controller() !== 'items') return;

  var isIndex = $p.action() === 'index';

  if (isIndex) {
    // カスタム要素のアップグレード完了を待ってから初期化
    customElements.whenDefined('grid-container').then(function () {
      var gridEl = document.querySelector('grid-container[data-scrollable]');
      var frame = gridEl && gridEl.shadowRoot
        ? gridEl.shadowRoot.querySelector('.app-grid-frame')
        : null;
      init(frame);
    });
  } else {
    init(null);
  }

  function init(frame) {
    var useGrid = Boolean(frame);

    // 十字パッドコンテナを生成
    var $pad = $('<div>', {
      class: 'scroll-pad' + (useGrid ? '' : ' sp-vertical-only')
    });

    // ボタン生成ヘルパー
    function createBtn(css, icon, title) {
      return $('<button>', { type: 'button', class: css, title: title })
        .append($('<span>', {
          class: 'material-symbols-outlined',
          text: icon
        }));
    }

    // 「上へ」
    var $up = createBtn('sp-up is-visible', 'arrow_upward', '先頭へ');
    $up.on('click', function () {
      if (useGrid) frame.scrollTo({ top: 0, behavior: 'smooth' });
      window.scrollTo({ top: 0, behavior: 'smooth' });
    });

    // 「下へ」
    var $down = createBtn('sp-down is-visible', 'arrow_downward', '末尾へ');
    $down.on('click', function () {
      if (useGrid) {
        frame.scrollTo({ top: frame.scrollHeight, behavior: 'smooth' });
      }
      window.scrollTo({
        top: document.documentElement.scrollHeight,
        behavior: 'smooth'
      });
    });

    // DOM に追加
    $pad.append($up).append($down);

    // 「左へ」「右へ」(一覧画面のみ)
    if (useGrid) {
      var $left = createBtn('sp-left is-visible', 'arrow_back', '左端へ');
      $left.on('click', function () {
        frame.scrollTo({ left: 0, behavior: 'smooth' });
      });

      var $right = createBtn('sp-right is-visible', 'arrow_forward', '右端へ');
      $right.on('click', function () {
        frame.scrollTo({ left: frame.scrollWidth, behavior: 'smooth' });
      });

      $pad.append($left).append($right);
    }

    $('body').append($pad);
  }
});

拡張スタイル(要点) ​

.scroll-pad を position: fixed(right: 24px; bottom: 24px; z-index: 900)で固定し、4 つのボタンを position: absolute で十字型に並べます。ボタンは opacity: 0; pointer-events: none を初期値とし、.is-visible で表示します(スクリプトは全ボタンに is-visible を付けて常時表示しています)。.sp-vertical-only のときは幅 36px の上下 2 ボタンにします。スタイル全文は抜粋の下に載せます。

css
.scroll-pad {
  position: fixed;
  right: 24px;
  bottom: 24px;
  width: 84px;
  height: 84px;
  z-index: 900;
}

.scroll-pad button {
  position: absolute;
  width: 36px;
  height: 36px;
  border: none;
  border-radius: 50%;
  background: #455a64;
  color: #fff;
  /* …中略… */
  opacity: 0;
  pointer-events: none;
}

.scroll-pad button.is-visible {
  opacity: 1;
  pointer-events: auto;
}

.scroll-pad .sp-up    { top: 0;    left: 50%; transform: translateX(-50%); }
.scroll-pad .sp-down  { bottom: 0; left: 50%; transform: translateX(-50%); }
.scroll-pad .sp-left  { left: 0;   top: 50%;  transform: translateY(-50%); }
.scroll-pad .sp-right { right: 0;  top: 50%;  transform: translateY(-50%); }

.scroll-pad.sp-vertical-only {
  width: 36px;
  height: 84px;
}
ExtendedStyles/ScrollButtons.css
css
/* --- 十字パッド型コンテナ --- */
.scroll-pad {
  position: fixed;
  right: 24px;
  bottom: 24px;
  width: 84px;
  height: 84px;
  z-index: 900;
}

/* --- 共通ボタンスタイル --- */
.scroll-pad button {
  position: absolute;
  width: 36px;
  height: 36px;
  border: none;
  border-radius: 50%;
  background: #455a64;
  color: #fff;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
  transition: opacity 0.2s, background 0.2s;
  opacity: 0;
  pointer-events: none;
  padding: 0;
}

.scroll-pad button .material-symbols-outlined {
  font-size: 20px;
}

.scroll-pad button.is-visible {
  opacity: 1;
  pointer-events: auto;
}

.scroll-pad button:hover {
  background: #37474f;
}

/* --- 各ボタンの配置(十字型) --- */
.scroll-pad .sp-up {
  top: 0;
  left: 50%;
  transform: translateX(-50%);
}

.scroll-pad .sp-down {
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
}

.scroll-pad .sp-left {
  left: 0;
  top: 50%;
  transform: translateY(-50%);
}

.scroll-pad .sp-right {
  right: 0;
  top: 50%;
  transform: translateY(-50%);
}

/* --- 中央の装飾円 --- */
.scroll-pad::after {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: rgba(69, 90, 100, 0.3);
  pointer-events: none;
}

/* --- window スクロール時はコンパクトに --- */
.scroll-pad.sp-vertical-only {
  width: 36px;
  height: 84px;
}

.scroll-pad.sp-vertical-only::after {
  display: none;
}

.scroll-pad.sp-vertical-only .sp-up {
  top: 0;
  left: 50%;
  transform: translateX(-50%);
}

.scroll-pad.sp-vertical-only .sp-down {
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
}

色は .scroll-pad button の background(ホバー時は .scroll-pad button:hover)、位置は .scroll-pad の right / bottom を変えて調整します。

v2 テーマの配色モードとテーマを切り替える(統合 FAB) 1.4 以降 ​

画面左下の FAB(Floating Action Button)1 つから、v2 テーマの配色モード(デフォルト・ダーク・ハイコントラスト・色覚異常対応)とテーマ(Cerulean・Green Tea・Mandarin・Midnight)の両方を切り替えられるようにします。メインボタン(palette アイコン)をクリックすると、配色モードが上方向、テーマが右方向に同時に展開する L 字型のメニューです。

テーマ改善で前提が変わります

2027 年 1 月予定のテーマ改善では、標準でカラーモード(ライト / ダーク / 自動)とハイコントラストのテーマが選べるようになり、このレシピが使う CSS 変数の多く(--commonColor*・--nonColor*・--base-* など)は定義が無くなります。詳しくは テーマ改善(UI テーマの刷新)への備え を見てください。

単機能版をまとめた統合版です

配色モード切替とテーマ切替は、もともと別々の FAB として作られていました(後述の単機能版)。どちらも画面左下に置かれるため、両方を導入するとボタンが重なります。統合 FAB はこの 2 つを 1 つのボタンにまとめたもので、ここに載せた CSS 1 本(UnifiedFab.css)と JavaScript 1 本(UnifiedFab.js)だけで完結します。単機能版のコードを事前に導入しておく必要はありません。これから導入する場合は統合版を使ってください。

前提条件

  • バージョン 1.4 以降の v2 テーマ(cerulean・green-tea・mandarin・midnight)が対象です。
  • テーマ切替には、ユーザーのプロフィール変更が許可されている必要があります。App_Data/Parameters/Service.json の ShowProfiles が true(既定値。Service.json)である必要があります(変更後はプリザンターの再起動が必要)。false の場合、特権ユーザとテナント管理を許可されたユーザ以外は、ユーザ更新 API($p.apiUsersUpdate)が InvalidRequest のエラーになり、テーマ切替は動作しません(UserValidators.cs、Permissions.cs)。
  • Api.json の Compatibility_1_3_12 が false(既定値)であることが前提です。true の環境では API キーを付けないリクエストの ApiVersion が無視されるため、ブラウザの $p.api~ 関数での ApiVersion 指定が反映されません(Context.cs)。

仕組み ​

方向展開する選択肢永続化
上方向 ↑配色モード(デフォルト・ダーク・ハイコントラスト・色覚異常対応)の 4 つlocalStorage + Sessions API(ユーザー単位)
右方向 →テーマ(Cerulean・Green Tea・Mandarin・Midnight)の 4 つ$p.apiUsersUpdate で Users テーブルの Theme 列

図を読み込み中…

配色モード ​

v2 テーマは配色を CSS カスタムプロパティ(CSS 変数)で管理しているため、body にクラスを付けて変数を上書きするだけで画面全体の配色を変えられます。

モードbody のクラス方針
デフォルトなしテーマ標準の配色
ダークモードcm-dark背景を暗色、テキストを明色に
ハイコントラストcm-high-contrastテーマの配色を保ちつつテキスト・ボーダーを強調し、WCAG AAA 相当のコントラスト比に
色覚異常対応cm-cvdOkabe-Ito パレットを基にした配色。P 型・D 型色覚でも区別しやすい

選んだモードは localStorage(即時復元用のキャッシュ)と Sessions API(SavePerUser: true でユーザー単位のセッション値として保存。サーバースクリプトの context.UserData とは別物です。詳しくは セッション間のデータ共有)の 2 か所に保存します。ページ読み込み時は localStorage で即座に適用してちらつきを防ぎ、その後 Sessions API の値を非同期に取得して、別のデバイスで変更された場合に同期します。

テーマ ​

v2 テーマはプロフィール編集画面(NavigationMenus.json の AccountMenu_EditProfile のリンク先 {ApplicationPath}users/{userId}/edit)の Users_Theme ドロップダウンで変更でき、保存すると Users コントローラーの Update アクションが呼ばれます。

図を読み込み中…

つまりテーマの切替は標準のユーザー更新なので、FAB からは $p.apiUsersUpdate でユーザーの Theme を更新してページをリロードします。設定はサーバー側に保存されるので localStorage や Sessions API での保存は不要で、テーマごとの CSS 変数の上書きも要りません(リロード後にプリザンターが正しいテーマの CSS を読み込みます)。

使われるテーマの決まり方

画面のテーマは、ユーザーのテーマ → テナントのテーマ → User.json の Theme → cerulean の順に、最初に見つかった有効な値です。1.5.8.1 では、値がテーマの選択肢(Users_Theme.json の ChoicesText)に無ければ飛ばします(Context.cs#L1523-L1542)。v2 テーマかどうかは ThemeVersion() にテーマ名を直接書いて判定しています(Context.cs#L1544-L1556)。本体にテーマを追加する場合の手順は アクセシビリティテーマの追加 にあります。

図を読み込み中…

DOM 構造 ​

text
.uf-fab(コンテナ:position: fixed で左下に固定)
  ├─ .uf-fab-main(メインボタン:palette アイコン)
  ├─ .uf-fab-up(上方向グループ:配色モード)
  │   ├─ .uf-fab-item[data-key="normal"]         palette
  │   ├─ .uf-fab-item[data-key="dark"]           dark_mode
  │   ├─ .uf-fab-item[data-key="high-contrast"]  contrast
  │   └─ .uf-fab-item[data-key="cvd"]            accessibility
  └─ .uf-fab-right(右方向グループ:テーマ)
      ├─ .uf-fab-item[data-key="cerulean"]   背景色: #106ebe
      ├─ .uf-fab-item[data-key="green-tea"]  背景色: #058266
      ├─ .uf-fab-item[data-key="mandarin"]   背景色: #eb9151
      └─ .uf-fab-item[data-key="midnight"]   背景色: #7a43b1

拡張スタイル ​

App_Data/Parameters/ExtendedStyles/UnifiedFab.css に置きます。FAB の見た目と、配色モードの CSS 変数上書き(ダーク・ハイコントラスト・色覚異常対応)をすべてこのファイルに含めています。

ブロック内容
.uf-fab / .uf-fab-main画面左下(left: 24px; bottom: 24px)に固定。is-open でメインボタンを 45 度回転
.uf-fab-upflex-direction: column-reverse で下から積み上げる配色モードのグループ。translateY(20px) → translateY(0) で展開
.uf-fab-rightflex-direction: row で左から並べるテーマのグループ。選択肢はテーマのプライマリカラーのカラーチップ。translateX(-20px) → translateX(0) で展開
::afterdata-label のツールチップ。上方向の選択肢では右横、右方向の選択肢では上に表示して重ならないようにする
@media (max-width: 640px)狭い画面では位置を left: 12px; bottom: 12px に
body.cm-dark--page-bg・--base-text・--base-bg のほか、ボタン・フォームコントロール・グリッド・エディタ・モーダル・jQuery UI などの変数を上書き
body.cm-high-contrast--nonColor* 系やテキスト #000・ボーダー #222 などを上書き
body.cm-high-contrast[data-v2-theme="…"]テーマごとに --primaryColor などを WCAG AAA(7:1 以上)の暗色に差し替え
body.cm-cvd--primaryColor・--commonColor*・ボタン・成功/警告色を Okabe-Ito パレットで再割当。リンク下線・必須欄の二重線など色以外のマーカーも追加

派生変数は直接上書きする

:root で --btn-normal-label: var(--base-text) のように定義されている派生変数は、定義元(:root)で値が解決されます。そのため body で --base-text だけを変えても派生変数は追従しません。--control-text や --btn-normal-label など、テキスト系の派生変数もすべて直接上書きする必要があります。

ExtendedStyles/UnifiedFab.css(全文)
css
/* =============================================
   統合 FAB メニュー(コンテナ)
   ============================================= */
.uf-fab {
  position: fixed;
  left: 24px;
  bottom: 24px;
  z-index: 900;
  --uf-ease: cubic-bezier(0.2, 0.8, 0.2, 1);
}

/* ── メインボタン ── */
.uf-fab-main {
  width: 48px;
  height: 48px;
  min-width: 48px;
  min-height: 48px;
  border: 1px solid rgba(255, 255, 255, 0.35);
  border-radius: 50% !important;
  background: var(--primaryColor, #106ebe);
  background-image: none !important;
  color: var(--invert-text, #fff);
  cursor: pointer;
  display: inline-flex !important;
  align-items: center;
  justify-content: center;
  box-shadow: 0 8px 18px rgba(0, 0, 0, 0.28);
  transition: background 0.2s, transform 0.3s var(--uf-ease), box-shadow 0.2s;
  padding: 0;
  position: relative;
  z-index: 2;
  overflow: hidden;
  appearance: none;
  -webkit-appearance: none;
  box-sizing: border-box;
  line-height: 1;
}

.uf-fab-main:hover {
  background: var(--primaryDark, #005a9e);
  box-shadow: 0 10px 24px rgba(0, 0, 0, 0.34);
}

.uf-fab.is-open .uf-fab-main {
  transform: rotate(45deg) scale(0.98);
}

.uf-fab-main .material-symbols-outlined {
  font-size: 24px;
  line-height: 1;
  display: block;
  width: 1em;
  height: 1em;
  overflow: hidden;
}

/* =============================================
   上方向グループ(配色モード)
   ============================================= */
.uf-fab-up {
  position: absolute;
  bottom: 56px;
  left: 4px;
  display: flex !important;
  flex-direction: column-reverse !important;
  align-items: center;
  gap: 8px;
  transform-origin: center bottom;
}

/* =============================================
   右方向グループ(テーマ)
   ============================================= */
.uf-fab-right {
  position: absolute;
  bottom: 4px;
  left: 56px;
  display: flex !important;
  flex-direction: row !important;
  align-items: center;
  gap: 8px;
  transform-origin: left center;
}

/* =============================================
   選択肢ボタン(共通)
   ============================================= */
.uf-fab-item {
  width: 40px;
  height: 40px;
  min-width: 40px;
  min-height: 40px;
  border: 1px solid rgba(0, 0, 0, 0.1);
  border-radius: 50% !important;
  background-image: none !important;
  cursor: pointer;
  display: inline-flex !important;
  align-items: center;
  justify-content: center;
  box-shadow: 0 4px 10px rgba(0, 0, 0, 0.24);
  padding: 0;
  opacity: 0;
  pointer-events: none;
  position: relative;
  overflow: hidden;
  appearance: none;
  -webkit-appearance: none;
  box-sizing: border-box;
  line-height: 1;
}

/* ── 上方向アイテムのアニメーション ── */
.uf-fab-up .uf-fab-item {
  background: var(--nonColor16, #fff);
  color: var(--nonColor03, #333);
  transform: scale(0.3) translateY(20px);
  transition: opacity 0.25s var(--uf-ease), transform 0.25s var(--uf-ease), border-color 0.2s,
    background 0.2s;
}

/* ── 右方向アイテムのアニメーション ── */
.uf-fab-right .uf-fab-item {
  transform: scale(0.3) translateX(-20px);
  border-color: rgba(255, 255, 255, 0.75);
  background-image: none !important;
  transition: opacity 0.25s var(--uf-ease), transform 0.25s var(--uf-ease), border-color 0.2s;
}

/* ── 展開時(上方向) ── */
.uf-fab.is-open .uf-fab-up .uf-fab-item {
  opacity: 1;
  transform: scale(1) translateY(0);
  pointer-events: auto;
}

.uf-fab.is-open .uf-fab-up .uf-fab-item:nth-child(1) { transition-delay: 0.03s; }
.uf-fab.is-open .uf-fab-up .uf-fab-item:nth-child(2) { transition-delay: 0.06s; }
.uf-fab.is-open .uf-fab-up .uf-fab-item:nth-child(3) { transition-delay: 0.09s; }
.uf-fab.is-open .uf-fab-up .uf-fab-item:nth-child(4) { transition-delay: 0.12s; }

/* ── 展開時(右方向) ── */
.uf-fab.is-open .uf-fab-right .uf-fab-item {
  opacity: 1;
  transform: scale(1) translateX(0);
  pointer-events: auto;
}

.uf-fab.is-open .uf-fab-right .uf-fab-item:nth-child(1) { transition-delay: 0.03s; }
.uf-fab.is-open .uf-fab-right .uf-fab-item:nth-child(2) { transition-delay: 0.06s; }
.uf-fab.is-open .uf-fab-right .uf-fab-item:nth-child(3) { transition-delay: 0.09s; }
.uf-fab.is-open .uf-fab-right .uf-fab-item:nth-child(4) { transition-delay: 0.12s; }

/* ── アクティブ状態(配色モード) ── */
.uf-fab-up .uf-fab-item.is-active {
  border-color: var(--primaryColor, #106ebe);
  background: var(--primarySub04, #eff6fc);
  color: var(--primaryColor, #106ebe);
}

/* ── アクティブ状態(テーマ) ── */
.uf-fab-right .uf-fab-item.is-active {
  border-color: var(--nonColor01, #1f1f1f);
  box-shadow: 0 0 0 2px var(--nonColor16, #fff), 0 5px 12px rgba(0, 0, 0, 0.35);
}

/* ── ホバー ── */
.uf-fab-up .uf-fab-item:hover {
  background: var(--nonColor14, #f5f5f5);
  transform: scale(1.04) translateY(0);
}

.uf-fab-right .uf-fab-item:hover {
  opacity: 0.92;
  transform: scale(1.04) translateX(0);
}

.uf-fab-main:focus-visible,
.uf-fab-item:focus-visible {
  outline: 2px solid var(--primaryColor, #106ebe);
  outline-offset: 2px;
}

.uf-fab-up .uf-fab-item .material-symbols-outlined {
  font-size: 20px;
  line-height: 1;
  display: block;
  width: 1em;
  height: 1em;
  overflow: hidden;
}

/* ── 狭い画面では右方向グループを折り返して崩れを防ぐ ── */
@media (max-width: 640px) {
  .uf-fab {
    left: 12px;
    bottom: 12px;
  }

  .uf-fab-right {
    max-width: none;
    flex-wrap: nowrap;
    row-gap: 6px;
  }
}

/* ── ツールチップ(上方向:右横に表示) ── */
.uf-fab-up .uf-fab-item::after {
  content: attr(data-label);
  position: absolute;
  left: 52px;
  white-space: nowrap;
  background: rgba(0, 0, 0, 0.75);
  color: #fff;
  font-size: 12px;
  padding: 4px 10px;
  border-radius: 4px;
  z-index: 3;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s;
}

.uf-fab-up .uf-fab-item:hover::after {
  opacity: 1;
}

/* ── ツールチップ(右方向:上に表示) ── */
.uf-fab-right .uf-fab-item::after {
  content: attr(data-label);
  position: absolute;
  bottom: 52px;
  white-space: nowrap;
  background: rgba(0, 0, 0, 0.75);
  color: #fff;
  font-size: 12px;
  padding: 4px 10px;
  border-radius: 4px;
  z-index: 3;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s;
}

.uf-fab-right .uf-fab-item:hover::after {
  opacity: 1;
}

/* =============================================
   ダークモード
   ============================================= */
body.cm-dark {
  /* ── ベース ── */
  --page-bg: #333;
  --base-text: #f5f5f5;
  --base-bg: #1f1f1f;
  --base-bg-light: #3c3c3c;
  --base-border: #666;
  --base-dark-layer: rgb(0 0 0 / 50%);
  --base-shadow: rgb(0 0 0 / 50%);
  --invert-text: #fff;
  --invert-border: #8f8f8f;
  --link-text: #6db3f2;
  --scrollbar-thumb-hover: #aeaeae;
  --defaultIconUrl: url('../images/ui-icons_ffffff_256x240.png');
  /* ── プライマリサブ ── */
  --primarySub01: #444;
  --primarySub02: #3c3c3c;
  --primarySub03: #333;
  --primarySub04: #2a2a2a;
  /* ── ボタン ── */
  --btn-normal-label: #f5f5f5;
  --btn-normal-bg: #515151;
  --btn-normal-hover: #666;
  --btn-normal-border: #8f8f8f;
  --btn-neutral-bg: #666;
  --btn-neutral-border: #8f8f8f;
  --btn-neutral-hover: #515151;
  /* ── フォームコントロール ── */
  --control-text: #f5f5f5;
  --control-text-read: #aeaeae;
  --control-border: #666;
  --control-border-focus: #8f8f8f;
  --control-bg: #444;
  --control-bg-focus: #3c3c3c;
  --control-bg-read: #3c3c3c;
  /* ── グリッド ── */
  --grid-cell-bg: #222;
  --grid-cell-hover: #3c3c3c;
  --grid-cell-border: #515151;
  --grid-cell-vborder: #444;
  --grid-cell-heading: #3c3c3c;
  --grid-comment-bg: #2a2a2a;
  --grid-comment-border: #444;
  --grid-focus-inform-bg: #333;
  --grid-focus-inform-border: #e69f00;
  /* ── エディタ・サイトパネル ── */
  --editor-bg: #1f1f1f;
  --editer-comment-bg: #2a2a2a;
  --site-panal-bg: #1f1f1f;
  --selectable-bg: #1f1f1f;
  --selectable-border: #666;
  --selectable-btn-bg: #3c3c3c;
  --selectable-btn-border: #8f8f8f;
  /* ── レイアウト ── */
  --breadcrumb-bg: #1f1f1f;
  --guide-bg: #3c3c3c;
  --guide-text: #cecece;
  --hamburger-trigger-icon: #cecece;
  --hamburger-outer-bg: rgb(0 0 0 / 50%);
  --hamburger-opener-icon: #aeaeae;
  --hamburger-subnavi-bg: #3c3c3c;
  --hamburger-subnavi-hover: #444;
  --recommend-guide-link-text: #6db3f2;
  --footer-command-bg: rgb(0 0 0 / 70%);
  --scrollbar-thumb: #8f8f8f;
  /* ── フィールドセットグループ ── */
  --fieldset-group-border: #666;
  --fieldset-group-header-bg: #3c3c3c;
  --fieldset-group-header--text: #f5f5f5;
  --fieldset-group-header-border: #666;
  /* ── モーダル・ツールチップ ── */
  --u-modal-body-bg: #222;
  --tooltip-text: #fff;
  --tooltip-bg: rgb(50 50 50 / 90%);
  /* ── jQuery UI ── */
  --ui-tabs-bg: #3c3c3c;
  --ui-tabs-btn: #444;
  --ui-tabs-btn-border: #666;
  --ui-multiselect-header-bg: #3c3c3c;
  --ui-multiselect-header-border: #666;
  /* ── パスワード・テンプレート ── */
  --password-tool-icon: #cecece;
  --template-viewer-bg: #2a2a2a;
  --template-viewer-detail: #333;
  --template-description-bg: #3c3c3c;
  --template-warning-text: #ff6b6b;
  --template-warning-bg: #3c2020;
  --start-guide-hover: #3c3c3c;
  /* ── モーダル(追加分) ── */
  --u-modal-scroll: #666;
  --u-modal-scroll-hover: #8f8f8f;
  --u-modal-footer-bg: rgb(0 0 0 / 70%);
}

/* =============================================
   ハイコントラストモード(共通)
   ============================================= */
body.cm-high-contrast {
  /* ── ベース ── */
  --nonColor01: #000;
  --nonColor02: #000;
  --nonColor08: #111;
  --nonColor12: #ccc;
  --nonColor14: #eee;
  --page-bg: #d0d0d0;
  --base-text: #000;
  --base-bg: #fff;
  --base-bg-light: #f0f0f0;
  --base-border: #222;
  --base-shadow: rgb(0 0 0 / 40%);
  /* ── ボタン ── */
  --btn-normal-label: #000;
  --btn-normal-bg: #fff;
  --btn-normal-border: #222;
  --btn-neutral-bg: #222;
  --btn-neutral-border: #000;
  /* ── フォームコントロール ── */
  --control-text: #000;
  --control-text-read: #111;
  --control-border: #222;
  --control-border-focus: #000;
  --control-bg: #fff;
  --control-bg-focus: #ffe;
  --control-bg-read: #e8e8e8;
  /* ── グリッド ── */
  --grid-cell-bg: #fff;
  --grid-cell-border: #333;
  --grid-cell-vborder: #999;
  --grid-cell-heading: #ddd;
  --grid-focus-inform-bg: #fff;
  /* ── エディタ・選択アイテム ── */
  --editor-bg: #fff;
  --site-panal-bg: #fff;
  --selectable-bg: #fff;
  --selectable-border: #222;
  --selectable-btn-bg: #f0f0f0;
  --selectable-btn-border: #222;
  /* ── フィールドセットグループ ── */
  --fieldset-group-border: #222;
  --fieldset-group-header--text: #000;
  --fieldset-group-header-bg: #ddd;
  --fieldset-group-header-border: #222;
  /* ── モーダル・ツールチップ ── */
  --u-modal-body-bg: #fff;
  --u-modal-scroll: #666;
  --u-modal-scroll-hover: #333;
  --u-modal-footer-bg: #f0f0f0;
  --tooltip-text: #000;
  --tooltip-bg: rgb(255 255 255 / 95%);
  /* ── jQuery UI ── */
  --ui-tabs-bg: #f0f0f0;
  --ui-tabs-btn: #fff;
  --ui-tabs-btn-border: #222;
  --ui-multiselect-header-bg: #f0f0f0;
  --ui-multiselect-header-border: #222;
}

/* ── テーマ別ハイコントラスト(プライマリ色) ──
   data-v2-theme 属性は拡張スクリプトで自動設定される。
   各テーマの --primaryColor を WCAG AAA(7:1 以上)に
   準拠する暗色に差し替え、テーマ固有のアクセントを維持する */

/* Cerulean */
body.cm-high-contrast[data-v2-theme="cerulean"] {
  --link-text: #0000cc;
  --primaryColor: #003c8f;
  --primaryDark: #001d4a;
  --primarySub01: #668fbf;
  --primarySub02: #b3c7df;
  --primarySub03: #d6e3f0;
  --primarySub04: #eaf1f8;
  --btn-positive-bg: #003c8f;
  --btn-positive-hover: #001d4a;
}

/* Green Tea */
body.cm-high-contrast[data-v2-theme="green-tea"] {
  --link-text: #034d3c;
  --primaryColor: #034d3c;
  --primaryDark: #022e24;
  --primarySub01: #5c9c8a;
  --primarySub02: #a5cfc3;
  --primarySub03: #d0e8e2;
  --primarySub04: #e8f4f0;
  --btn-positive-bg: #034d3c;
  --btn-positive-hover: #022e24;
}

/* Mandarin */
body.cm-high-contrast[data-v2-theme="mandarin"] {
  --link-text: #7a4010;
  --primaryColor: #7a4010;
  --primaryDark: #5c3008;
  --primarySub01: #b88c5c;
  --primarySub02: #dbc1a3;
  --primarySub03: #edded0;
  --primarySub04: #f7f0e8;
  --btn-positive-bg: #7a4010;
  --btn-positive-hover: #5c3008;
}

/* Midnight */
body.cm-high-contrast[data-v2-theme="midnight"] {
  --link-text: #4a1f80;
  --primaryColor: #4a1f80;
  --primaryDark: #2d1350;
  --primarySub01: #8f6db3;
  --primarySub02: #c4b1d9;
  --primarySub03: #e0d5ed;
  --primarySub04: #f0eaf5;
  --btn-positive-bg: #4a1f80;
  --btn-positive-hover: #2d1350;
}

/* =============================================
   色覚異常対応モード(Okabe-Ito パレット)
   ============================================= */
body.cm-cvd {
  /* ── プライマリ ── */
  --primaryColor: #0072b2;
  --primaryDark: #004c75;
  --primarySub01: #90c8e8;
  --primarySub02: #c1e2f5;
  --primarySub03: #dff0fb;
  --primarySub04: #eef7fd;
  --link-text: #0072b2;
  /* ── 共通カラー ── */
  --commonColor01: #d55e00;
  --commonColor02: #cc6633;
  --commonColor03: #ffd6c2;
  --commonColor04: #ffe8db;
  --commonColor05: #fff7e5;
  --commonColor06: #e69f00;
  --commonColor07: rgb(0 114 178 / 90%);
  /* ── ボタン ── */
  --btn-negative-bg: #d55e00;
  --btn-negative-hover: #b34d00;
  --btn-positive-bg: #0072b2;
  --btn-positive-hover: #004c75;
  /* ── 成功・警告 ── */
  --success-color: rgb(0 158 115 / 90%);
  --warning-color: #d55e00;
  --control-error: #d55e00;
}

/* ── CVD モード:色以外の視覚マーカー ── */
body.cm-cvd a {
  text-decoration: underline !important;
  text-underline-offset: 2px;
}

body.cm-cvd .field-control.must {
  border-style: double !important;
  border-width: 3px !important;
}

body.cm-cvd .button-icon.delete,
body.cm-cvd .button-icon.negative {
  border-left: 4px solid #b34d00 !important;
}

body.cm-cvd tr.ui-state-highlight td {
  border-left: 3px dotted #e69f00 !important;
}

body.cm-cvd .error,
body.cm-cvd .control-error {
  border-left: 4px solid #d55e00 !important;
  padding-left: 8px;
}

ハイコントラストモードのテーマ別プライマリカラーは次のとおりです。data-v2-theme 属性は拡張スクリプトが body に設定します。

テーマdata-v2-themeプライマリダークコントラスト比
Ceruleancerulean#003c8f#001d4a10.3:1
Green Teagreen-tea#034d3c#022e249.9:1
Mandarinmandarin#7a4010#5c30088.2:1
Midnightmidnight#4a1f80#2d135011.7:1

Cerulean と色覚異常対応モード

Cerulean は青系で、Okabe-Ito の青(#0072b2)と既定の色(#106ebe)が近いため、色覚異常対応モードに切り替えても色の変化は小さくなります。そのためリンクの下線や必須欄の二重ボーダーなど、色以外の手がかりを追加しています。

拡張スクリプト ​

App_Data/Parameters/ExtendedScripts/UnifiedFab.js に置きます。

ExtendedScripts/UnifiedFab.js(全文)
js
$(function () {
  // items コントローラーのみ対象
  if ($p.controller() !== 'items') return;

  // v2 テーマ判定(CSS カスタムプロパティの有無で確認)
  var hasCustomProps = getComputedStyle(document.documentElement)
    .getPropertyValue('--primaryColor').trim();
  if (!hasCustomProps) return;

  /* ── テーマ検出 ── */
  var theme = ($('#Theme').val() || 'cerulean').toLowerCase();
  document.body.setAttribute('data-v2-theme', theme);

  /* ========================================
     配色モード定義
     ======================================== */
  var SESSION_KEY = 'ColorMode';
  var modes = [
    { key: 'normal',        label: 'デフォルト',       icon: 'palette',       css: '' },
    { key: 'dark',          label: 'ダークモード',     icon: 'dark_mode',     css: 'cm-dark' },
    { key: 'high-contrast', label: 'ハイコントラスト', icon: 'contrast',      css: 'cm-high-contrast' },
    { key: 'cvd',           label: '色覚異常対応',     icon: 'accessibility', css: 'cm-cvd' }
  ];

  /* ========================================
     テーマ定義
     ======================================== */
  var themes = [
    { key: 'cerulean',  label: 'Cerulean',  color: '#106ebe' },
    { key: 'green-tea', label: 'Green Tea', color: '#058266' },
    { key: 'mandarin',  label: 'Mandarin',  color: '#eb9151' },
    { key: 'midnight',  label: 'Midnight',  color: '#7a43b1' }
  ];

  /* ========================================
     配色モード:初期化
     ======================================== */
  var currentModeKey = localStorage.getItem(SESSION_KEY) || 'normal';
  applyMode(currentModeKey);
  loadModeFromSession();

  /* ========================================
     テーマ:ユーザー情報を取得して FAB を構築
     ======================================== */
  $p.apiUsersGet({
    id: $p.userId(),
    data: { ApiVersion: 1.1 },
    done: function (data) {
      var user = data.Response.Data[0];
      var currentThemeKey = user.Theme || 'cerulean';
      buildFab(currentThemeKey);
    }
  });

  /* ========================================
     FAB メニュー生成
     ======================================== */
  function buildFab(currentThemeKey) {
    var $fab = $('<div>', { class: 'uf-fab' });

    // メインボタン
    var $main = $('<button>', {
      type: 'button',
      class: 'uf-fab-main',
      title: '表示設定'
    }).append(
      $('<span>', { class: 'material-symbols-outlined', text: 'palette' })
    );

    $main.on('click', function (e) {
      e.stopPropagation();
      $fab.toggleClass('is-open');
    });

    $fab.append($main);

    // ── 上方向グループ(配色モード) ──
    var $upGroup = $('<div>', { class: 'uf-fab-up' });

    modes.forEach(function (mode) {
      var $item = $('<button>', {
        type: 'button',
        class: 'uf-fab-item' + (mode.key === currentModeKey ? ' is-active' : ''),
        title: mode.label,
        'data-key': mode.key,
        'data-label': mode.label
      }).append(
        $('<span>', { class: 'material-symbols-outlined', text: mode.icon })
      );

      $item.on('click', function (e) {
        e.stopPropagation();
        var key = $(this).data('key');
        applyMode(key);
        saveMode(key);
        $upGroup.find('.uf-fab-item').removeClass('is-active');
        $(this).addClass('is-active');
        $fab.removeClass('is-open');
      });

      $upGroup.append($item);
    });

    $fab.append($upGroup);

    // ── 右方向グループ(テーマ) ──
    var $rightGroup = $('<div>', { class: 'uf-fab-right' });

    themes.forEach(function (theme) {
      var $item = $('<button>', {
        type: 'button',
        class: 'uf-fab-item' + (theme.key === currentThemeKey ? ' is-active' : ''),
        title: theme.label,
        'data-key': theme.key,
        'data-label': theme.label
      }).css('background-color', theme.color);

      $item.on('click', function (e) {
        e.stopPropagation();
        var key = $(this).data('key');
        if (key === currentThemeKey) {
          $fab.removeClass('is-open');
          return;
        }
        updateTheme(key);
      });

      $rightGroup.append($item);
    });

    $fab.append($rightGroup);

    $('body').append($fab);

    // 外側クリックでメニューを閉じる
    $(document).on('click', function () {
      $fab.removeClass('is-open');
    });

    $fab.on('click', function (e) {
      e.stopPropagation();
    });
  }

  /* ========================================
     配色モード:適用
     ======================================== */
  function applyMode(key) {
    modes.forEach(function (m) {
      if (m.css) document.body.classList.remove(m.css);
    });
    var mode = modes.find(function (m) { return m.key === key; });
    if (mode && mode.css) {
      document.body.classList.add(mode.css);
    }
    currentModeKey = key;
  }

  function buildSessionApiBase() {
    var appPath = $('#ApplicationPath').val() || '/';
    if (appPath.slice(-1) !== '/') appPath += '/';
    return appPath + 'api/sessions/';
  }

  function sessionApiCandidates(action) {
    var lower = String(action || '').toLowerCase();
    var upperHead = lower.charAt(0).toUpperCase() + lower.slice(1);
    var base = buildSessionApiBase();
    return [
      base + lower,
      base + upperHead,
      '/api/sessions/' + lower,
      '/api/sessions/' + upperHead
    ];
  }

  function callSessionApi(action, body, done, fail) {
    var urls = sessionApiCandidates(action);

    function tryNext(index) {
      if (index >= urls.length) {
        if (typeof fail === 'function') fail({ status: 404 });
        return;
      }

      $.ajax({
        url: urls[index],
        type: 'POST',
        contentType: 'application/json',
        data: JSON.stringify(body),
        success: function (data) {
          if (typeof done === 'function') done(data);
        },
        error: function (xhr) {
          if (xhr && (xhr.status === 404 || xhr.status === 405)) {
            tryNext(index + 1);
            return;
          }
          if (typeof fail === 'function') fail(xhr);
        }
      });
    }

    tryNext(0);
  }

  /* ========================================
     配色モード:保存(localStorage + Sessions API)
     ======================================== */
  function saveMode(key) {
    localStorage.setItem(SESSION_KEY, key);
    callSessionApi('get', {
      ApiVersion: 1.1,
      SessionKey: SESSION_KEY,
      SavePerUser: true
    }, function () {
      callSessionApi('set', {
        ApiVersion: 1.1,
        SessionKey: SESSION_KEY,
        SessionValue: key,
        SavePerUser: true
      });
    }, function (xhr) {
      if (xhr && xhr.status === 404) {
        callSessionApi('set', {
          ApiVersion: 1.1,
          SessionKey: SESSION_KEY,
          SessionValue: key,
          SavePerUser: true
        });
      }
    });
  }

  /* ========================================
     配色モード:Sessions API から読み込み
     ======================================== */
  function loadModeFromSession() {
    callSessionApi('get', {
      ApiVersion: 1.1,
      SessionKey: SESSION_KEY,
      SavePerUser: true
    }, function (data) {
      if (data && data.Response && data.Response.Value) {
        var key = data.Response.Value;
        var mode = modes.find(function (m) { return m.key === key; });
        if (mode && key !== currentModeKey) {
          applyMode(key);
          localStorage.setItem(SESSION_KEY, key);
          $('.uf-fab-up .uf-fab-item').removeClass('is-active');
          $('.uf-fab-up [data-key="' + key + '"]').addClass('is-active');
        }
      }
    }, function (xhr) {
      // Get の 404 はキー未登録(未保存)として扱う
      if (xhr && xhr.status !== 404) {
        console.warn('Sessions API get failed:', xhr.status);
      }
    });
  }

  /* ========================================
     テーマ:ユーザー更新 API で変更
     ======================================== */
  function updateTheme(key) {
    $p.apiUsersUpdate({
      id: $p.userId(),
      data: {
        ApiVersion: 1.1,
        Theme: key
      },
      done: function () {
        location.reload();
      },
      fail: function () {
        alert('テーマの変更に失敗しました。');
      }
    });
  }
});

スクリプトは先頭で次の 2 つを確認し、当てはまらなければ何もしません。

条件判定方法目的
items コントローラー$p.controller() !== 'items'管理画面等ではボタンを表示しない
v2 テーマgetComputedStyle で --primaryColor を取得v1 テーマでは CSS 変数が未定義で空文字列になるため対象外
  • $('#Theme').val() で現在のテーマ名を取り、body の data-v2-theme 属性に入れます(取得できなければ cerulean)。ハイコントラストモードのテーマ別配色に使います。
  • FAB は $p.apiUsersGet のコールバック内で組み立てます。ユーザーの現在のテーマ(Response.Data[0].Theme、未設定なら cerulean 扱い)を取得してからメニューを作るので、現在のテーマにアクティブ表示を正しく付けられます。
  • 配色モードの選択肢をクリックすると、CSS クラスの付け替えで即座に配色が変わり、localStorage と Sessions API に保存してメニューを閉じます。
  • テーマの選択肢をクリックすると $p.apiUsersUpdate でテーマを更新し、成功したら location.reload()、失敗したらアラートを出します。同じテーマを選んだときはメニューを閉じるだけで API は呼びません。$p.apiUsersUpdate は認証やリクエストトークンを内部で処理するので、CSRF トークンを手動で取得する必要はありません。
  • Sessions API はログイン中のセッションクッキーで認証されるため、API キーは不要です。

図を読み込み中…

カスタマイズ ​

位置を変える:.uf-fab の left / bottom を変えます。右下に置く場合は、テーマのグループの展開方向も逆にすると自然です。なお、スクロールボタン(.scroll-pad)も右下(right: 24px; bottom: 24px)に配置されます。

diff
 .uf-fab {
-  left: 24px;
-  bottom: 24px;
+  right: 24px;
+  bottom: 24px;
 }
diff
 .uf-fab-right {
   position: absolute;
   bottom: 4px;
-  left: 56px;
+  right: 56px;
   display: flex;
-  flex-direction: row;
+  flex-direction: row-reverse;
   align-items: center;
   gap: 8px;
 }

配色モードを追加する:CSS にクラスを追加し、スクリプトの modes 配列にエントリを足します。テーマの選択肢も themes 配列の編集で変更できます。

css
/* セピアモード */
body.cm-sepia {
  --base-text: #5b4636;
  --base-bg: #f4ecd8;
  --base-bg-light: #eee5d0;
  --page-bg: #e8dcc8;
  --base-border: #c4a882;
  --link-text: #8b4513;
}
diff
   var modes = [
     { key: 'normal',        label: 'デフォルト',       icon: 'palette',       css: '' },
     { key: 'dark',          label: 'ダークモード',     icon: 'dark_mode',     css: 'cm-dark' },
     { key: 'high-contrast', label: 'ハイコントラスト', icon: 'contrast',      css: 'cm-high-contrast' },
-    { key: 'cvd',           label: '色覚異常対応',     icon: 'accessibility', css: 'cm-cvd' }
+    { key: 'cvd',           label: '色覚異常対応',     icon: 'accessibility', css: 'cm-cvd' },
+    { key: 'sepia',         label: 'セピアモード',     icon: 'filter_vintage', css: 'cm-sepia' }
   ];

単機能版の FAB ​

統合 FAB の元になった、配色モードだけ・テーマだけを切り替える FAB です。どちらも画面左下(left: 24px; bottom: 24px)に配置されるので、両方を使いたい場合は前節の統合 FAB を使ってください(単機能版を併用するなら、どちらかの left / bottom(または right)を変える必要があります)。

単機能版ファイルクラス名永続化
配色モード切替(Sessions API 版)ColorMode.js / ColorMode.csscm-fab / cm-fab-main / cm-fab-itemlocalStorage + Sessions API
配色モード切替(localStorage 版)ColorMode.js / ColorMode.css同上localStorage のみ
テーマ切替ThemeSwitcher.js / ThemeSwitcher.cssts-fab / ts-fab-main / ts-fab-item$p.apiUsersUpdate

画面左下の統合 FAB を開いた様子(アクセシビリティ・コントラスト・ダークモード・テーマ色)

配色モード切替(Sessions API 版) ​

配色モードの 4 択を上方向に展開する FAB です。仕組み(body のクラス付け替え、localStorage + Sessions API の 2 層保存)は統合 FAB の配色モード部分と同じです。コードの違いとして、統合 FAB は Sessions API へのリクエストに ApiVersion: 1.1 を含めていますが、この版のスクリプトは含めていません。

ColorMode.css の配色モード部分(body.cm-dark・body.cm-high-contrast・テーマ別ハイコントラスト・body.cm-cvd と色以外のマーカー)は UnifiedFab.css と同じ内容で、FAB 部分だけがクラス名 cm-fab の縦 1 列のメニューになります。

ExtendedScripts/ColorMode.js(Sessions API 版・全文)
js
$(function () {
  // items コントローラーのみ対象
  if ($p.controller() !== 'items') return;

  // v2 テーマ判定(CSS カスタムプロパティの有無で確認)
  var hasCustomProps = getComputedStyle(document.documentElement)
    .getPropertyValue('--primaryColor').trim();
  if (!hasCustomProps) return;

  /* ── テーマ検出 ── */
  var theme = ($('#Theme').val() || 'cerulean').toLowerCase();
  document.body.setAttribute('data-v2-theme', theme);

  /* ── 設定 ── */
  var SESSION_KEY = 'ColorMode';

  /* ── モード定義 ── */
  var modes = [
    { key: 'normal',        label: 'デフォルト',       icon: 'palette',       css: '' },
    { key: 'dark',          label: 'ダークモード',     icon: 'dark_mode',     css: 'cm-dark' },
    { key: 'high-contrast', label: 'ハイコントラスト', icon: 'contrast',      css: 'cm-high-contrast' },
    { key: 'cvd',           label: '色覚異常対応',     icon: 'accessibility', css: 'cm-cvd' }
  ];

  /* ── 初期化(localStorage から即時復元) ── */
  var currentKey = localStorage.getItem(SESSION_KEY) || 'normal';
  applyMode(currentKey);

  /* ── Sessions API から非同期で検証 ── */
  loadFromSession();

  /* ── FAB メニュー生成 ── */
  var $fab = $('<div>', { class: 'cm-fab' });

  // メインボタン
  var $main = $('<button>', {
    type: 'button',
    class: 'cm-fab-main',
    title: '配色モード切替'
  }).append(
    $('<span>', { class: 'material-symbols-outlined', text: 'palette' })
  );

  $main.on('click', function (e) {
    e.stopPropagation();
    $fab.toggleClass('is-open');
  });

  $fab.append($main);

  // 選択肢ボタンを生成
  modes.forEach(function (mode) {
    var $item = $('<button>', {
      type: 'button',
      class: 'cm-fab-item' + (mode.key === currentKey ? ' is-active' : ''),
      title: mode.label,
      'data-key': mode.key,
      'data-label': mode.label
    }).append(
      $('<span>', { class: 'material-symbols-outlined', text: mode.icon })
    );

    $item.on('click', function (e) {
      e.stopPropagation();
      var key = $(this).data('key');
      applyMode(key);
      saveMode(key);
      // アクティブ状態を更新
      $fab.find('.cm-fab-item').removeClass('is-active');
      $(this).addClass('is-active');
      // メニューを閉じる
      $fab.removeClass('is-open');
    });

    $fab.append($item);
  });

  $('body').append($fab);

  // 外側クリックでメニューを閉じる
  $(document).on('click', function () {
    $fab.removeClass('is-open');
  });

  $fab.on('click', function (e) {
    e.stopPropagation();
  });

  /* ── 配色モードの適用 ── */
  function applyMode(key) {
    modes.forEach(function (m) {
      if (m.css) document.body.classList.remove(m.css);
    });
    var mode = modes.find(function (m) { return m.key === key; });
    if (mode && mode.css) {
      document.body.classList.add(mode.css);
    }
    currentKey = key;
  }

  function buildSessionApiBase() {
    var appPath = $('#ApplicationPath').val() || '/';
    if (appPath.slice(-1) !== '/') appPath += '/';
    return appPath + 'api/sessions/';
  }

  function sessionApiCandidates(action) {
    var lower = String(action || '').toLowerCase();
    var upperHead = lower.charAt(0).toUpperCase() + lower.slice(1);
    var base = buildSessionApiBase();
    return [
      base + lower,
      base + upperHead,
      '/api/sessions/' + lower,
      '/api/sessions/' + upperHead
    ];
  }

  function callSessionApi(action, body, done, fail) {
    var urls = sessionApiCandidates(action);

    function tryNext(index) {
      if (index >= urls.length) {
        if (typeof fail === 'function') fail({ status: 404 });
        return;
      }

      $.ajax({
        url: urls[index],
        type: 'POST',
        contentType: 'application/json',
        data: JSON.stringify(body),
        success: function (data) {
          if (typeof done === 'function') done(data);
        },
        error: function (xhr) {
          if (xhr && (xhr.status === 404 || xhr.status === 405)) {
            tryNext(index + 1);
            return;
          }
          if (typeof fail === 'function') fail(xhr);
        }
      });
    }

    tryNext(0);
  }

  /* ── モードの保存(localStorage + Sessions API) ── */
  function saveMode(key) {
    localStorage.setItem(SESSION_KEY, key);
    callSessionApi('get', {
      SessionKey: SESSION_KEY,
      SavePerUser: true
    }, function () {
      callSessionApi('set', {
        SessionKey: SESSION_KEY,
        SessionValue: key,
        SavePerUser: true
      });
    }, function (xhr) {
      if (xhr && xhr.status === 404) {
        callSessionApi('set', {
          SessionKey: SESSION_KEY,
          SessionValue: key,
          SavePerUser: true
        });
      }
    });
  }

  /* ── Sessions API からモードを読み込み ── */
  function loadFromSession() {
    callSessionApi('get', {
      SessionKey: SESSION_KEY,
      SavePerUser: true
    }, function (data) {
      if (data && data.Response && data.Response.Value) {
        var key = data.Response.Value;
        var mode = modes.find(function (m) { return m.key === key; });
        if (mode && key !== currentKey) {
          applyMode(key);
          localStorage.setItem(SESSION_KEY, key);
          $fab.find('.cm-fab-item').removeClass('is-active');
          $fab.find('[data-key="' + key + '"]').addClass('is-active');
        }
      }
    }, function (xhr) {
      // Get の 404 はキー未登録(未保存)として扱う
      if (xhr && xhr.status !== 404) {
        console.warn('Sessions API get failed:', xhr.status);
      }
    });
  }
});
ExtendedStyles/ColorMode.css(FAB 部分)
css
/* =============================================
   FAB メニュー(コンテナ)
   ============================================= */
.cm-fab {
  position: fixed;
  left: 24px;
  bottom: 24px;
  z-index: 900;
  display: flex;
  flex-direction: column-reverse;
  align-items: center;
  gap: 8px;
}

/* ── メインボタン ── */
.cm-fab-main {
  width: 48px;
  height: 48px;
  border: none;
  border-radius: 50%;
  background: var(--primaryColor, #106ebe);
  color: var(--invert-text, #fff);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 3px 8px rgba(0, 0, 0, 0.35);
  transition: background 0.2s, transform 0.3s;
  padding: 0;
}

.cm-fab-main:hover {
  background: var(--primaryDark, #005a9e);
}

.cm-fab.is-open .cm-fab-main {
  transform: rotate(45deg);
}

.cm-fab-main .material-symbols-outlined {
  font-size: 24px;
}

/* ── 選択肢ボタン ── */
.cm-fab-item {
  width: 40px;
  height: 40px;
  border: 2px solid transparent;
  border-radius: 50%;
  background: var(--nonColor16, #fff);
  color: var(--nonColor03, #333);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
  padding: 0;
  opacity: 0;
  transform: scale(0.3) translateY(20px);
  pointer-events: none;
  transition: opacity 0.25s, transform 0.25s, border-color 0.2s,
    background 0.2s;
}

.cm-fab.is-open .cm-fab-item {
  opacity: 1;
  transform: scale(1) translateY(0);
  pointer-events: auto;
}

.cm-fab.is-open .cm-fab-item:nth-child(2) { transition-delay: 0.03s; }
.cm-fab.is-open .cm-fab-item:nth-child(3) { transition-delay: 0.06s; }
.cm-fab.is-open .cm-fab-item:nth-child(4) { transition-delay: 0.09s; }

.cm-fab-item:hover {
  background: var(--nonColor14, #f5f5f5);
}

.cm-fab-item.is-active {
  border-color: var(--primaryColor, #106ebe);
  background: var(--primarySub04, #eff6fc);
  color: var(--primaryColor, #106ebe);
}

.cm-fab-item .material-symbols-outlined {
  font-size: 20px;
}

/* ── ツールチップ(選択肢の左横) ── */
.cm-fab-item::after {
  content: attr(data-label);
  position: absolute;
  left: 52px;
  white-space: nowrap;
  background: rgba(0, 0, 0, 0.75);
  color: #fff;
  font-size: 12px;
  padding: 4px 10px;
  border-radius: 4px;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s;
}

.cm-fab-item:hover::after {
  opacity: 1;
}

/* 以下、配色モードの CSS 変数上書き(UnifiedFab.css の body.cm-dark 以降と同じ) */

配色モード切替(localStorage 版) ​

Sessions API 版から Sessions API の呼び出しを取り除き、localStorage だけで保存する版です。見た目・機能は同じで、拡張スタイル(ColorMode.css)はそのまま使え、変わるのはスクリプトの保存部分だけです。

項目Sessions API 版localStorage 版
即時復元localStoragelocalStorage
永続化Sessions API(ユーザー単位のセッション値)localStorage のみ(キー ColorMode)
デバイス間共有できるできない(ブラウザ・デバイスに紐付く)
実装Sessions API の呼び出しが必要localStorage.setItem / getItem のみ

図を読み込み中…

Sessions API 版のスクリプトから callSessionApi・buildSessionApiBase・sessionApiCandidates・loadFromSession の 4 関数と loadFromSession() の呼び出しがなくなり、saveMode は localStorage.setItem の 1 行になります(キー名の定数も SESSION_KEY から STORAGE_KEY に変わっています)。API の非同期待ちがないので、ちらつきなく即時に復元できます。

ExtendedScripts/ColorMode.js(localStorage 版・全文)
js
$(function () {
  // items コントローラーのみ対象
  if ($p.controller() !== 'items') return;

  // v2 テーマ判定(CSS カスタムプロパティの有無で確認)
  var hasCustomProps = getComputedStyle(document.documentElement)
    .getPropertyValue('--primaryColor').trim();
  if (!hasCustomProps) return;

  /* ── テーマ検出 ── */
  var theme = ($('#Theme').val() || 'cerulean').toLowerCase();
  document.body.setAttribute('data-v2-theme', theme);

  /* ── 設定 ── */
  var STORAGE_KEY = 'ColorMode';

  /* ── モード定義 ── */
  var modes = [
    { key: 'normal',        label: 'デフォルト',       icon: 'palette',       css: '' },
    { key: 'dark',          label: 'ダークモード',     icon: 'dark_mode',     css: 'cm-dark' },
    { key: 'high-contrast', label: 'ハイコントラスト', icon: 'contrast',      css: 'cm-high-contrast' },
    { key: 'cvd',           label: '色覚異常対応',     icon: 'accessibility', css: 'cm-cvd' }
  ];

  /* ── 初期化(localStorage から即時復元) ── */
  var currentKey = localStorage.getItem(STORAGE_KEY) || 'normal';
  applyMode(currentKey);

  /* ── FAB メニュー生成 ── */
  var $fab = $('<div>', { class: 'cm-fab' });

  // メインボタン
  var $main = $('<button>', {
    type: 'button',
    class: 'cm-fab-main',
    title: '配色モード切替'
  }).append(
    $('<span>', { class: 'material-symbols-outlined', text: 'palette' })
  );

  $main.on('click', function (e) {
    e.stopPropagation();
    $fab.toggleClass('is-open');
  });

  $fab.append($main);

  // 選択肢ボタンを生成
  modes.forEach(function (mode) {
    var $item = $('<button>', {
      type: 'button',
      class: 'cm-fab-item' + (mode.key === currentKey ? ' is-active' : ''),
      title: mode.label,
      'data-key': mode.key,
      'data-label': mode.label
    }).append(
      $('<span>', { class: 'material-symbols-outlined', text: mode.icon })
    );

    $item.on('click', function (e) {
      e.stopPropagation();
      var key = $(this).data('key');
      applyMode(key);
      saveMode(key);
      // アクティブ状態を更新
      $fab.find('.cm-fab-item').removeClass('is-active');
      $(this).addClass('is-active');
      // メニューを閉じる
      $fab.removeClass('is-open');
    });

    $fab.append($item);
  });

  $('body').append($fab);

  // 外側クリックでメニューを閉じる
  $(document).on('click', function () {
    $fab.removeClass('is-open');
  });

  $fab.on('click', function (e) {
    e.stopPropagation();
  });

  /* ── 配色モードの適用 ── */
  function applyMode(key) {
    modes.forEach(function (m) {
      if (m.css) document.body.classList.remove(m.css);
    });
    var mode = modes.find(function (m) { return m.key === key; });
    if (mode && mode.css) {
      document.body.classList.add(mode.css);
    }
    currentKey = key;
  }

  /* ── モードの保存(localStorage のみ) ── */
  function saveMode(key) {
    localStorage.setItem(STORAGE_KEY, key);
  }
});

どちらを選ぶかの目安は次のとおりです。

要件推奨
複数デバイス・複数ブラウザで設定を共有したいSessions API 版
サーバーへのリクエストを減らしたいlocalStorage 版
Sessions API が使えない環境localStorage 版
シンプルな実装を優先したいlocalStorage 版
プライベートブラウズを多用するユーザーがいるSessions API 版

localStorage の制約

localStorage はブラウザのプライベートモードでは終了時にクリアされます。また、ブラウザのストレージ設定で無効化されている環境では動作しません。そのような要件がある場合は Sessions API 版を選んでください。

テーマ切替 ​

4 つの v2 テーマ(Cerulean #106ebe・Green Tea #058266・Mandarin #eb9151・Midnight #7a43b1)のカラーチップを上方向に展開する FAB です。仕組み($p.apiUsersGet で現在のテーマを取得し、$p.apiUsersUpdate で更新してリロード)と前提条件は統合 FAB のテーマ部分と同じです。$p.apiUsersGet に失敗した場合は FAB を作りません。ただし 確認したソースでは、ユーザ取得 API は ShowProfiles を判定しないため(UserValidators.cs)、ShowProfiles が false の環境でも FAB は表示され、テーマを選んだ時点で更新 API がエラーになってアラートが出ます。

ExtendedScripts/ThemeSwitcher.js(全文)
js
$(function () {
  /* ── テーマ定義 ── */
  var themes = [
    { key: 'cerulean',  label: 'Cerulean',  color: '#106ebe' },
    { key: 'green-tea', label: 'Green Tea', color: '#058266' },
    { key: 'mandarin',  label: 'Mandarin',  color: '#eb9151' },
    { key: 'midnight',  label: 'Midnight',  color: '#7a43b1' }
  ];

  /* ── ユーザー情報を取得して現在のテーマを判定 ── */
  $p.apiUsersGet({
    id: $p.userId(),
    data: { ApiVersion: 1.1 },
    done: function (data) {
      var user = data.Response.Data[0];
      var currentKey = user.Theme || 'cerulean';
      buildFab(currentKey);
    }
  });

  /* ── FAB メニュー生成 ── */
  function buildFab(currentKey) {
    var $fab = $('<div>', { class: 'ts-fab' });

    // メインボタン
    var $main = $('<button>', {
      type: 'button',
      class: 'ts-fab-main',
      title: 'テーマ切替'
    }).append(
      $('<span>', { class: 'material-symbols-outlined', text: 'palette' })
    );

    $main.on('click', function (e) {
      e.stopPropagation();
      $fab.toggleClass('is-open');
    });

    $fab.append($main);

    // 選択肢ボタンを生成
    themes.forEach(function (theme) {
      var $item = $('<button>', {
        type: 'button',
        class: 'ts-fab-item' + (theme.key === currentKey ? ' is-active' : ''),
        title: theme.label,
        'data-key': theme.key,
        'data-label': theme.label
      }).css('background-color', theme.color);

      $item.on('click', function (e) {
        e.stopPropagation();
        var key = $(this).data('key');
        if (key === currentKey) {
          $fab.removeClass('is-open');
          return;
        }
        updateTheme(key);
      });

      $fab.append($item);
    });

    $('body').append($fab);

    // 外側クリックでメニューを閉じる
    $(document).on('click', function () {
      $fab.removeClass('is-open');
    });

    $fab.on('click', function (e) {
      e.stopPropagation();
    });
  }

  /* ── ユーザー更新 API でテーマを変更 ── */
  function updateTheme(key) {
    $p.apiUsersUpdate({
      id: $p.userId(),
      data: {
        ApiVersion: 1.1,
        Theme: key
      },
      done: function () {
        location.reload();
      },
      fail: function () {
        alert('テーマの変更に失敗しました。');
      }
    });
  }
});

App_Data/Parameters/ExtendedStyles/ThemeSwitcher.css の構造は .cm-fab とほぼ同じで、クラス名が ts-fab / ts-fab-main / ts-fab-item になり、選択肢ボタンはテーマのプライマリカラーのカラーチップ(background-color はスクリプトで設定)になります。

ExtendedStyles/ThemeSwitcher.css(抜粋)
css
.ts-fab {
  position: fixed;
  left: 24px;
  bottom: 24px;
  z-index: 900;
  display: flex;
  flex-direction: column-reverse;
  align-items: center;
  gap: 8px;
}

.ts-fab-item {
  /* …中略… */
  opacity: 0;
  transform: scale(0.3) translateY(20px);
  pointer-events: none;
}

.ts-fab.is-open .ts-fab-item {
  opacity: 1;
  transform: scale(1) translateY(0);
  pointer-events: auto;
}

.ts-fab-item.is-active {
  border-color: var(--nonColor01, #1f1f1f);
  box-shadow: 0 0 0 2px var(--nonColor16, #fff), 0 2px 6px rgba(0, 0, 0, 0.35);
}

関連ページ ​

変更履歴

第13版テーマ改善(UI テーマの刷新)で変わる点と事前確認の手順を追加する
第12版記事の確認版を繰り返す表現を整理する
第11版ナビゲーションとテーマのレシピにスクリーンショットを追加し、拡張ナビゲーションメニューと確認ゲートのコードを修正
第10版Markdown の描画の仕組み・ショートカットキー・アイコン・公式マニュアルに無い $p 関数の解説と、Markdown 拡張・画像形式・ファビコン・テーマ・和暦などの改修・設計メモを追加
第9版リンク項目の列指定と JOIN の組み立て、一覧のスクロール読み込みの解説と、一覧・カレンダー・サイトメニューまわりの改修・設計メモを追加
第8版履歴タブと復元・数値項目の通貨記号・画像プレビューモーダルの解説と、編集画面まわりの改修・設計メモを追加
第7版「拡張機能」「画面カスタマイズ集」に対応バージョンを表示
第6版ボタングループ化の CSS を 1.5.8.1 のラジオボタン・チェックボックスの構造に合わせて書き直し
第5版画面カスタマイズ集のコードを 1.5.8.1 のソースで検証し、動かなかったサンプルを修正
第4版画面カスタマイズ集にコード全体を収録し、サイト画像とサイト種別アイコンを両立するレシピを追加
第3版記事のファイル名に並び順の番号を付け、元記事リンクを frontmatter の sources に移行
第2版ナビゲーション・テーマに統合 FAB・メニューアイコン・確認ゲートなどを追加し、管理画面にコードフォーマッターを追加
第1版「画面カスタマイズ集」セクションの記事を追加