0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

プリザンターv2テーマにアクセシビリティ対応の色切替ボタンを追加してみる

0
Last updated at Posted at 2026-03-23

はじめに

ウェブアクセシビリティへの配慮はどんなシステムでも欠かせません。プリザンターの v2 テーマは CSS カスタムプロパティ(CSS 変数)でカラーが管理されているため、変数を上書きするだけでテーマ全体の配色を切り替えることができます。

この記事では、画面左下に FAB(Floating Action Button) を配置し、クリックでメニューを展開してダークモードハイコントラスト色覚異常対応の配色を選択できる仕組みを、拡張スクリプトと拡張スタイルで実装します。切り替えた配色は Sessions API 経由で context.UserData に保存するため、ブラウザやデバイスが変わっても設定が維持されます。

バージョン 1.4 以降の v2 テーマ(ceruleangreen-teamandarinmidnight)を対象にしています

仕組みを整理する

実装のポイントは次のとおりです。

項目 内容
対象テーマ v2 テーマ(ceruleangreen-teamandarinmidnight
切替モード デフォルト・ダーク・ハイコントラスト・色覚異常対応の 4 種類
ボタン配置 position: fixed で画面左下に固定する FAB
ボタン UI メインボタンをクリックするとモード選択ボタンが上方向に展開
アイコン Material Symbols(palettedark_modecontrastaccessibility
即時反映 CSS クラスの付け替えと localStorage で即座に配色が変わる
永続化 Sessions API(SavePerUser: true)で context.UserData に保存

配色モードの概要

各モードの配色ポリシーは次のとおりです。

モード CSS クラス 方針
デフォルト (なし) テーマ標準の配色
ダークモード cm-dark 背景を暗色、テキストを明色にして目の疲労を軽減
ハイコントラスト cm-high-contrast テーマの配色を維持しつつテキスト・ボーダーを強調し、コントラスト比を WCAG AAA 相当に引き上げる
色覚異常対応 cm-cvd Okabe-Ito パレットを基にした配色で P 型・D 型色覚でも区別しやすい

FAB メニューの動作

メインボタンをクリックすると選択肢が展開され、モードを選んで確定します。

永続化の仕組み

配色モードの保存には 2 つのレイヤーを使います。

レイヤー 役割 特徴
localStorage 即時キャッシュ ページ遷移時に配色がちらつかない
Sessions API サーバー永続化 SavePerUser: truecontext.UserData と同一領域に保存。デバイス間で共有可能

ページ読み込み時はまず localStorage から配色を復元し、そのあと Sessions API で最新値を非同期に取得して同期します。これによりちらつきのない即時反映デバイス間の一貫性を両立できます。

実装してみよう

拡張スタイルと拡張スクリプトの 2 ファイルで構成します。

拡張機能 役割
拡張スタイル 配色モードの CSS カスタムプロパティ上書きと FAB メニューの見た目
拡張スクリプト FAB メニューの生成・モード切替・Sessions API による永続化

配色モードのスタイル(拡張スタイル)

拡張スタイルとして App_Data/Parameters/ExtendedStyles/ に配置します。

ExtendedStyles/ColorMode.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;
}

/* =============================================
   ダークモード
   ============================================= */
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');
  /* ── プライマリサブ(ViewFilters・Aggregations 背景等) ── */
  --primarySub01: #444;
  --primarySub02: #3c3c3c;
  --primarySub03: #333;
  --primarySub04: #2a2a2a;
  /* ── ボタン ──
     :root で --btn-normal-label: var(--base-text) と定義済みだが
     CSS カスタムプロパティは定義元(:root)で値が解決されるため
     body で --base-text を変えても派生変数には伝播しない。
     そのためテキスト系の派生変数もすべて直接上書きする。 */
  --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 モード:色以外の視覚マーカー ──
   cerulean 等の青系テーマでは Okabe-Ito の青
   (#0072b2) とデフォルト (#106ebe) が近いため、
   色だけでは区別しにくい。ボーダースタイルや
   下線・アウトラインなど色以外の手がかりを追加する */

/* リンクに下線を常時表示 */
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;
}

ポイントをまとめます。

  • FAB メニューposition: fixed で画面左下に固定。メインボタンクリックで選択肢が上方向にアニメーション展開
  • 選択肢ボタンにはツールチップを CSS ::after 擬似要素で表示し、モード名が一目で分かる
  • ダークモード--page-bg--base-text--base-bg をはじめ、グリッド・フォームコントロール・モーダル・jQuery UI まで 50 以上の変数 を上書きし、画面全体が確実に暗転。CSS カスタムプロパティは定義元で解決されるため、:root--control-text: var(--base-text) と定義された派生変数は body--base-text を変えても追従しない点に注意し、--control-text--btn-normal-label などテキスト系の派生変数もすべて直接上書きしている
  • ハイコントラストモードでは --nonColor* 系の変数も直接上書きし、テキスト #000、ボーダー #222 で WCAG AAA 相当のコントラスト比を確保。data-v2-theme 属性でテーマを判別し、テーマごとに --primaryColor を WCAG AAA 準拠の暗色に差し替えるため、どの v2 テーマでもテーマ固有のアクセントカラーを維持したまま高コントラスト表示ができる
  • 色覚異常対応モードでは Okabe-Ito カラーパレット を採用し、--commonColor* に加えてボタン・成功/警告色も再割当。P 型(1 型色覚)・D 型(2 型色覚)のどちらでも区別しやすい配色。さらに色以外の視覚マーカー(リンク下線常時表示・必須欄の二重ボーダー・削除ボタンの左ボーダー・エラーの左ボーダー)を追加し、cerulean のような元々 Okabe-Ito に近い青系テーマでも切替が視覚的に分かるようにしている

FAB メニューの生成と永続化(拡張スクリプト)

拡張スクリプトとして App_Data/Parameters/ExtendedScripts/ に配置します。

ExtendedScripts/ColorMode.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);
      }
    });
  }
});

各処理のポイントを見ていきましょう。

実行条件

スクリプトの先頭で 2 つの条件をチェックします。

条件 判定方法 目的
items コントローラー $p.controller() !== 'items' 管理画面等ではボタンを表示しない
v2 テーマ --primaryColor の有無 v1 テーマでは CSS カスタムプロパティが未定義のため動作対象外

v2 テーマかどうかは getComputedStyle--primaryColor の値を取得して判定します。v1 テーマでは CSS カスタムプロパティが定義されていないため空文字列になります。

テーマ検出

v2 テーマの判定後、$('#Theme').val() で現在のテーマ名を取得し、bodydata-v2-theme 属性として設定します。CSS 側ではこの属性を使い、ハイコントラストモードのプライマリカラーをテーマごとに切り替えます。

テーマ data-v2-theme HC プライマリ HC ダーク コントラスト比
Cerulean cerulean #003c8f #001d4a 10.3:1
Green Tea green-tea #034d3c #022e24 9.9:1
Mandarin mandarin #7a4010 #5c3008 8.2:1
Midnight midnight #4a1f80 #2d1350 11.7:1

テーマが取得できない場合は cerulean をデフォルトとして使います。

FAB メニューの動き

FAB メニューの構造は以下のとおりです。

.cm-fab(コンテナ:column-reverse で下から積む)
  ├─ .cm-fab-main(メインボタン:常時表示)
  ├─ .cm-fab-item[data-key="normal"]
  ├─ .cm-fab-item[data-key="dark"]
  ├─ .cm-fab-item[data-key="high-contrast"]
  └─ .cm-fab-item[data-key="cvd"]
操作 動作
メインボタンをクリック is-open クラスを付け替え、選択肢ボタンが上方向にアニメーション展開
選択肢ボタンをクリック 配色を適用 → is-active を更新 → メニューを閉じる
外側をクリック メニューを閉じる(documentclick イベントで制御)

選択肢ボタンは opacity: 0 + pointer-events: none で非表示にし、is-open 時に opacity: 1 + scale(1) でアニメーション表示します。transition-delay を段階的に設定してカスケード効果を出しています。

即時復元と非同期検証

ページ読み込み時の処理は 2 段階です。

  1. 即時復元: localStorage から保存済みモードを読み取り、DOM 描画前に CSS クラスを適用。ちらつきなし
  2. 非同期検証: Sessions API で context.UserData の値を取得し、異なるデバイスで変更された場合に同期

Sessions API の呼び出し

Sessions API はログイン済みユーザーのセッションクッキーで認証されるため、API キーは不要です。saveMode 関数では localStorage への保存に加え、Sessions API に SavePerUser: true でリクエストを送信します。これにより context.UserData.ColorMode と同じ領域にデータが永続化されます。

カスタマイズ

ボタンの位置を変える

FAB の位置は leftbottom の値を変更するだけで調整できます。

ExtendedStyles/ColorMode.css
 .cm-fab {
-  left: 24px;
-  bottom: 24px;
+  right: 24px;
+  bottom: 24px;
 }

モードを追加する

たとえばセピアモードを追加する場合は、CSS にクラス定義を追加し、JavaScript の modes 配列にエントリを追加します。

ExtendedStyles/ColorMode.css
+/* セピアモード */
+body.cm-sepia {
+  --base-text: #5b4636;
+  --base-bg: #f4ecd8;
+  --base-bg-light: #eee5d0;
+  --page-bg: #e8dcc8;
+  --base-border: #c4a882;
+  --link-text: #8b4513;
+}
ExtendedScripts/ColorMode.js
   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' }
   ];

Okabe-Ito パレットの色を調整する

色覚異常対応モードの配色は Okabe-Ito パレットに基づいていますが、環境に合わせて --commonColor* の値を調整できます。

変数 設定値 Okabe-Ito での役割
--commonColor01 #d55e00 バーミリオン(既定テーマの赤を置換)
--commonColor06 #e69f00 オレンジ
--commonColor07 rgb(0 114 178 / 90%) ブルー(成功色と連動)

実際の画面をみてみる

image.png

デフォルトの表示画面がこちらになります。左下にカラー切替のボタンが表示されているので、これをクリックしてみます。

image.png

上から順に色覚異常モード(Okabe-Ito パレット)、ハイコントラスト、ダークモード、デフォルトとなります。それぞれの色の変化を一覧と編集画面で見てみます。(その他の画面はぜひ試して見てください)

色覚異常モード(Okabe-Ito パレット)

image.png
image.png

実はCeruleanは青系カラーなので元々P 型・D 型色覚にやさしいカラーパレットになっているので、あまり変化がありません。

ハイコントラスト

image.png
image.png

ダークモード

image.png
image.png

まとめ

プリザンターの v2 テーマに、アクセシビリティ対応の配色切替 FAB メニューを拡張スタイルと拡張スクリプトだけで追加しました。

  • 画面左下の FAB メニューをクリックすると 4 つのモード選択ボタンが展開し、選んで確定する直感的な UI
  • ダークモード: 50 以上の CSS 変数を上書きし、派生変数も直接指定して背景・テキスト・グリッド・フォーム・モーダルまで確実に暗転
  • ハイコントラストモード: テキスト #000・ボーダー #222 で WCAG AAA 相当のコントラスト比。テーマ別に --primaryColor を自動調整し、どのテーマでも固有の配色を活かした高コントラスト表示が可能
  • 色覚異常対応モード: Okabe-Ito パレットでボタン・警告色・成功色まで再割当し P 型・D 型色覚に対応。リンク下線・二重ボーダーなど色以外のマーカーも追加
  • Sessions API はセッションクッキーで認証されるため API キー不要。localStorage で即時復元し context.UserData に永続化
  • modes 配列と CSS クラスの追加だけでモードを拡張可能
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?