kintoneのDOM要素を使わないカスタマイズの提案～モーダル表示～

## はじめに
みなさんこんにちは。サイボウズ公認 kintoneエバンジェリスト、プロジェクト・アスノートの松田です。

この記事は、[kintone - Qiita Advent Calendar 2024 - Qiita](https://qiita.com/advent-calendar/2024/kintone)の22日目の記事となります。

## 非推奨DOMについて
かねてから、[cybozu developer network](https://cybozu.dev/ja/)では、kintone独自のDOM要素（Class名やid）は予告なく変更することがあるので、カスタマイズには使用しないように、と記載されています。とはいっても、見た目上のカスタマイズはそれなりにニーズはあったりするので、まぁ、自己責任でということだと思います。

https://cybozu.dev/ja/kintone/docs/guideline/coding-guideline/
kintoneコーディングガイドライン「kintoneで使われているidやclass属性」参照


一方、kintone JavaScript APIで用意されているものもありますが、レコードのフォーム画面に関しては、各フィールドの要素を取得するAPIが提供されています。
[要素の取得 - cybozu developer network](https://cybozu.dev/ja/kintone/docs/js-api/#get-element)
[フィールド要素を取得する - cybozu developer network](https://cybozu.dev/ja/kintone/docs/js-api/record/get-record-field-element/)

## カスタマイズが求められるニーズ
さまざまなシーンが考えられますが、その１つに、「**情報を目立たせたい**」というものがあると思います。
kitnoneのレコード画面（詳細画面、編集画面）では、複数のフィールドがある場合、それらすべてのフィールドが対等な関係にあり、「このデータは大事なので目立たせたい」という要望に応えるための機能がありません。

基本機能で工夫するのであれば、グループフィールドに入れてまとめたり、ラベルフィールドを使って、注釈等を入れるというやり方があると思います。

１つ実例を挙げて考えてみましょう。プロセス管理のステータスが今何なのか？
ステータスは大事な情報なので、フォームの一番上に表示されるレイアウトになっています。
ただ、文字の大きさや色は他のフィールドと変わらないため、特に大きなディスプレイで作業している場合や、iPadやスマホを使って現場で閲覧・編集する場合には、パッと判断できないという困りごとがあります。

そこで、下の図のサンプルのように、画面内のステータスの文字サイズや文字カラーを変更したりすることで目立たせることができます。

![スクリーンショット_121124_035325_PM.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87971/91008fc9-80da-4cba-5ba4-363d8383f84d.jpeg)

ただし、今回行っているステータスについては、**kintoneに用意されている JavaScript APIでは取得できない**ため、kintone独自のDOM要素（クラス名、id）を使ってカスタマイズする必要があります。

https://cybozu.dev/ja/kintone/docs/js-api/record/get-record-field-element/

## 非推奨DOMを使わない方法の提案

上のような、非推奨DOMを使ったカスタマイズは、kintoneのアップデート等による変更が予告なく行われることがあり、それまで動いていたカスタマイズが不具合を起こしてしまう可能性があります。

実際、今年2024年にそのような、kintoneアプリ画面のクラス名の変更があり、既存のカスタマイズが動かなくなるという事案が発生しました。もちろん正式なアナウンスはなかったため、自己責任で確認・対応をする必要があります。

そのようなリスク（自己責任管理）がある非推奨DOMカスタマイズを使わない方法として、モーダル表示を使ったカスタマイズを紹介します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87971/b2eec88b-ab78-d112-5751-46b3deb0849a.png)
図．PC画面での表示

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87971/75ea4a7b-d053-0a6c-2d83-db110c088b45.png)
図．スマートフォン画面での表示例

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87971/e5a154f3-ae3e-47c9-c0c5-9a4540f23e79.png)
図．iPad画面での表示例


## JavaScriptコード

```JavaScript
(() => {
    'use strict';

    /**
     * ステータスに応じたスタイル設定を返す
     * @param {string} status ステータス値
     * @returns {Object} 背景色と文字色の設定
     */
    const getStatusColors = (status) => {
        const defaultColors = { background: 'rgba(204, 204, 204, 0.8)', color: '#000000' };
        const statusColors = {
            '対応中': { background: 'rgba(255, 165, 0, 0.8)', color: '#000000' },
            'ペンディング': { background: 'rgba(255, 255, 0, 0.8)', color: '#000000' },
            '中断': { background: 'rgba(0, 255, 0, 0.8)', color: '#000000' },
            '完了': { background: 'rgba(0, 255, 255, 0.8)', color: '#000000' }
        };
        return statusColors[status] || defaultColors;
    };

    /**
     * モーダルのスタイルを設定
     * @param {HTMLElement} modal モーダル要素
     * @param {boolean} isMobile モバイルかどうか
     */
    const setModalStyle = (modal, isMobile) => {
        modal.classList.add(isMobile ? 'modal-mobile' : 'modal-desktop');
    };

    /**
     * ドラッグ移動のイベントリスナーを設定
     * @param {HTMLElement} modal モーダル要素
     */
    const setDragEvents = (modal) => {
        let isDragging = false, offsetX = 0, offsetY = 0;

        const startDrag = (e) => {
            isDragging = true;
            const { clientX, clientY } = e.touches ? e.touches[0] : e;
            const rect = modal.getBoundingClientRect();
            offsetX = clientX - rect.left;
            offsetY = clientY - rect.top;
        };

        const duringDrag = (e) => {
            if (!isDragging) return;
            const { clientX, clientY } = e.touches ? e.touches[0] : e;
            const boundedLeft = Math.max(0, Math.min(clientX - offsetX, window.innerWidth - modal.offsetWidth));
            const boundedTop = Math.max(0, Math.min(clientY - offsetY, window.innerHeight - modal.offsetHeight));
            Object.assign(modal.style, {
                left: `${boundedLeft}px`,
                top: `${boundedTop}px`,
                transform: 'none',
                right: 'auto'
            });
        };

        const endDrag = () => { isDragging = false; };

        ['mousedown', 'touchstart'].forEach(event => modal.addEventListener(event, startDrag));
        ['mousemove', 'touchmove'].forEach(event => modal.addEventListener(event, duringDrag));
        ['mouseup', 'touchend'].forEach(event => modal.addEventListener(event, endDrag));
    };

    /**
     * モーダル要素を生成
     * @param {string} processStatus ステータス
     * @param {string} comments コメント
     * @param {Object} colors ステータスの色設定
     * @returns {HTMLElement} モーダル要素
     */
    const createModalContent = (processStatus, comments, colors) => {
        const modal = document.createElement('div');
        modal.className = 'status-modal';
        Object.assign(modal.style, { backgroundColor: colors.background, color: colors.color });

        const statusDiv = document.createElement('div');
        statusDiv.className = 'status-title';
        statusDiv.textContent = processStatus;

        const commentsDiv = document.createElement('div');
        commentsDiv.className = 'status-comments';
        commentsDiv.textContent = comments;

        modal.appendChild(statusDiv);
        modal.appendChild(commentsDiv);

        return modal;
    };

    /**
     * モーダルを表示する
     * @param {Object} event kintoneイベントオブジェクト
     * @param {boolean} isMobile モバイルかどうか
     */
    const showModal = (event, isMobile) => {
        const record = event.record;
        const processStatus = record['ステータス'].value;
        const comments = record['問い合わせ種別'].value;

        const modalId = 'status-modal';
        const existingModal = document.getElementById(modalId);
        if (existingModal) existingModal.remove();

        const colors = getStatusColors(processStatus);
        const modal = createModalContent(processStatus, comments, colors);
        modal.id = modalId;
        setModalStyle(modal, isMobile);
        setDragEvents(modal);

        document.body.appendChild(modal);
    };

    // イベント登録
    const events = ['app.record.detail.show', 'mobile.app.record.detail.show'];
    kintone.events.on(events, (event) => {
        const isMobile = event.type === 'mobile.app.record.detail.show';
        showModal(event, isMobile);
    });
})();

```

## CSS

```CSS
@charset "UTF-8";

.status-modal {
    position: fixed;
    border-radius: 8px;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
    cursor: move;
    z-index: 1000;
    transition: transform 0.05s ease-out;
}

.modal-desktop {
    top: 50px;
    right: 50px;
    width: 300px;
    height: 180px;
}

.modal-mobile {
    top: 45px;
    right: 10px;
    width: 240px;
    height: 160px;
}

.status-title {
    font-size: 24px;
    font-weight: bold;
}

.status-comments {
    font-size: 16px;
    font-weight: normal;
    margin-top: 5px;
    text-align: center;
}
```

## コード解説
構造的にはシンプルです。
レコード詳細画面表示イベントのイベントオブジェクトからレコードのデータを取得し、モーダルで表示させます。
コードの大半は、モーダルのスタイル設定と、モーダルウィンドウの操作に関する処理となっています。

このコードは、ユーザーが特定の操作を行うことで、動的にモーダルウィンドウを表示し、それを操作できるようにするものです。以下はコードがどのようにユーザーの操作に反応するかを詳細に説明したものです。

なお、JavaScript内で今回表示させるモーダル表示に、表示形式（PC／モバイル）ごとにクラス名をつけています。
各表示要素の基本スタイルについては、CSSでまとめて設定しています。こちらを変更することで、表示スタイルを変更することができます。
ステータスごとにモーダルウィンドウの色を変更したいので、これはJavaScript内で設定しています。

---

### 動作概要
1. **kintoneイベントに基づくモーダルの表示**
   - kintoneアプリの「レコード詳細画面」が表示されるイベント（`app.record.detail.show` や `mobile.app.record.detail.show`）に反応。
   - レコードのステータスや問い合わせ種別を読み取り、それに基づいたスタイルと内容のモーダルを動的に生成します。

2. **モーダルのドラッグ移動**
   - ユーザーがモーダルをドラッグする操作を検知し、画面上でモーダルを移動可能にします。

---

### 具体的な動作の流れ

#### 1. イベントリスナーの登録
```javascript
const events = ['app.record.detail.show', 'mobile.app.record.detail.show'];
kintone.events.on(events, (event) => {
    const isMobile = event.type === 'mobile.app.record.detail.show';
    showModal(event, isMobile);
});
```
- **ユーザー操作**: kintoneでレコード詳細画面を開く。
- **動作**: イベントタイプを確認して、モバイル版かデスクトップ版かを判別し、`showModal`関数を呼び出します。

#### 2. モーダルの生成と表示
```javascript
const showModal = (event, isMobile) => {
    const record = event.record;
    const processStatus = record['ステータス'].value;
    const comments = record['問い合わせ種別'].value;

    const colors = getStatusColors(processStatus);
    const modal = createModalContent(processStatus, comments, colors);
    modal.id = 'status-modal';
    setModalStyle(modal, isMobile);
    setDragEvents(modal);

    document.body.appendChild(modal);
};
```
- **ユーザー操作**: モーダルが表示される。
- **動作**:
  1. レコードデータを取得して、ステータスやコメントを読み取る。
  2. `getStatusColors` 関数でステータスに応じた背景色と文字色を決定。
  3. `createModalContent` 関数で内容が埋め込まれたモーダル要素を生成。
  4. モバイルかデスクトップに応じたスタイルを設定。
  5. モーダルをドラッグ移動可能にするイベントリスナーを追加。
  6. モーダルを`document.body`に追加。

#### 3. ドラッグ移動
```javascript
const setDragEvents = (modal) => {
    let isDragging = false, offsetX = 0, offsetY = 0;

    const startDrag = (e) => {
        isDragging = true;
        const { clientX, clientY } = e.touches ? e.touches[0] : e;
        const rect = modal.getBoundingClientRect();
        offsetX = clientX - rect.left;
        offsetY = clientY - rect.top;
    };

    const duringDrag = (e) => {
        if (!isDragging) return;
        const { clientX, clientY } = e.touches ? e.touches[0] : e;
        const boundedLeft = Math.max(0, Math.min(clientX - offsetX, window.innerWidth - modal.offsetWidth));
        const boundedTop = Math.max(0, Math.min(clientY - offsetY, window.innerHeight - modal.offsetHeight));
        Object.assign(modal.style, {
            left: `${boundedLeft}px`,
            top: `${boundedTop}px`,
            transform: 'none',
            right: 'auto'
        });
    };

    const endDrag = () => { isDragging = false; };

    ['mousedown', 'touchstart'].forEach(event => modal.addEventListener(event, startDrag));
    ['mousemove', 'touchmove'].forEach(event => modal.addEventListener(event, duringDrag));
    ['mouseup', 'touchend'].forEach(event => modal.addEventListener(event, endDrag));
};
```
- **ユーザー操作**:
  - モーダルをクリックしてドラッグ開始。
  - ドラッグ中にカーソルやタッチ操作でモーダルを移動。
  - ドラッグを終了すると、モーダルがその位置に固定される。
- **動作**:
  1. `startDrag`: ドラッグ開始時の位置を記録。
  2. `duringDrag`: カーソルの動きに応じてモーダルの位置を更新。
  3. `endDrag`: ドラッグ状態を終了し、モーダルの位置を確定。

#### 4. スタイル設定
```javascript
const setModalStyle = (modal, isMobile) => {
    modal.classList.add(isMobile ? 'modal-mobile' : 'modal-desktop');
};
```
- **ユーザー操作**: デバイス（モバイルかデスクトップ）による表示。
- **動作**: モーダルのスタイルをデバイスに応じて適切なものに切り替え。

---

### ユーザー操作例
1. レコード詳細画面を開く → モーダルが表示される。
2. モーダルをドラッグ操作 → モーダルが自由に動かせる。
3. ステータスやコメントに応じたカスタムスタイルが適用される。

## さいごに
kintoneのフォーム画面内部をあれこれイジる（kintone独自DOM要素を使ったり、DOM構造を書き換えたりすること）ことをせずに、見た目カスタマイズを行うための１つのアイデアとして見ていただけたらと思います。

応用例としては、一覧画面における表示や、モーダル内にさらに機能を追加したりということが考えられます。

kintoneのアップデートの頻度も高くなり、内容もだんだんと深いところに及んできました。今後もこのような内部構造のアップデート（id、クラス名の変更やDOM構造の変更）は、各種機能の進化に伴って行われるものだと考えるべきでしょう。

kintoneのUIカスタマイズを行う際は、まず最初に**本当にこのカスタマイズが必要か？**ということを考える必要があります。
とはいえ、利用するユーザーの人数やリテラシー、利用シーン、アプリの利用頻度によっては見た目のカスタマイズが大きな改善効果を生み出すことも事実です。

UIカスタマイズを行う際は、継続的なメンテナンス性を確保することや、自分以外のアプリ管理者にノウハウを共有することが大切ですね。

https://kintone.cybozu.co.jp/kintone-signpost/pattern/3-29.html