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?

【kintone】数値フィールドにスピンボタンを付ける

0
Last updated at Posted at 2025-10-06

例として、テストの点を入力するフィールドを作る。

フィールド名とフィールドコードは点数、最小値0、最大値100、単位記号は
数値フィールドを作り、その右に要素IDがspaceのスペースを置く。

タイトルなし.png

JavaScript / CSSでカスタマイズ では kuc.min.js 、下記の sample.js の順番にアップロード。

sample.js
(() => {
  'use strict';

  const fieldCode = '点数'; // スピンボタンで値を操作する数値フィールドのフィールドコード
  let fieldExists = false;  // 上記の数値フィールドの存在確認に使う変数
  let spaceElement = null;  // スペースフィールドの要素を格納
  let text = null;          // kintone UI Component の Textコンポーネント
  let inputElement = null;  // Textコンポーネントへフォーカス中に値を読み取るための変数
  let minValue = -Infinity; // Textコンポーネントの最小値を負の無限大で初期化
  let maxValue = Infinity;  // Textコンポーネントの最大値を正の無限大で初期化

  // Textコンポーネントに数値として正しい値のみ入力可能にするフォーマット
  const processInput = (inputElement) => {
    let value = inputElement.value
      // 全角数字を半角数字に変換
      .replace(/[0-9]/g, (char) =>
        String.fromCharCode(char.charCodeAt(0) - 0xFEE0)
      )
      // 全角マイナスを半角マイナスに変換
      .replace(/[ー-−]/g, '-')
      // 全角小数点を半角小数点に変換
      .replace(/./g, '.')
      // 数字、マイナス、小数点以外の文字を削除
      .replace(/[^0-9.\-]/g, '');

    // マイナスは1文字のみ許可し、2文字目以降のマイナスを削除
    if (value.includes('-')) {
      const firstMinus = value.indexOf('-');
      value = value.substring(0, firstMinus + 1) +
        value.substring(firstMinus + 1).replace(/-/g, '');
      // 先頭以外にマイナスがある場合は先頭に移動
      if (firstMinus > 0) value = '-' + value.replace(/-/g, '');
    }

    // 小数点は1つのみ許可し、2つ目以降を削除
    const dotIndex = value.indexOf('.');
    if (dotIndex !== -1) {
      value = value.substring(0, dotIndex + 1) +
        value.substring(dotIndex + 1).replace(/\./g, '');
    }

    // 小数点が先頭にあったら削除
    if (value.startsWith('.')) value = value.substring(1);
    // 小数点がマイナスの直後にあったら削除
    if (value.startsWith('-.')) value = '-' + value.substring(2);

    // フォーマット後の値が変更されている場合のみ値を更新
    if (value !== inputElement.value) inputElement.value = value;
  };

  // スピンボタンクリック時の値更新
  const createUpdateValueFunction = (delta, initialValue) => () => {
    let value;
    const currentValue = inputElement ? inputElement.value : text.value;
    
    // 値が空の場合は数値フィールドに初期値が設定されていたら初期値を入力
    if (!currentValue) {
      if (initialValue) {
        value = parseFloat(initialValue);
      } else if (delta > 0) {
        // ▲押下時は 最大値 → 正の最小値 → 0 の優先度で値を入力
        value = maxValue !== Infinity ? maxValue :
          (minValue > 0 ? minValue : 0);
      } else {
        // ▼押下時は 最小値 → 負の最大値 → 0 の優先度で値を入力
        value = minValue !== -Infinity ? minValue :
          (maxValue < 0 ? maxValue : 0);
      }
    // 値が範囲外の場合は境界値に補正
    } else {
      value = parseFloat(currentValue);
      if (value > maxValue) {
        value = maxValue;
      } else if (value < minValue) {
        value = minValue;
      } else {
        // 値が範囲内の場合は、▲押下時はインクリメント、▼押下時はデクリメント
        value += delta;
        // 浮動小数点数演算の誤差を丸める
        value = Math.round(value * 10000000000) / 10000000000;
        // 値が1増減後に範囲外になった場合は境界値に補正
        if (value > maxValue) value = maxValue;
        if (value < minValue) value = minValue;
      }
    }
    
    // inputElementとtext.valueの両方を更新
    const newValueStr = value.toString();
    if (inputElement) {
      inputElement.value = newValueStr;
    }
    text.value = newValueStr;
  };

  // スピンボタンの生成
  const createSpinButton = (symbol, className, updateFunction) => {
    const button = document.createElement('button');
    button.className = `custom-spinner-button ${className}`;
    button.innerHTML = symbol;
    button.type = 'button';

    // スピンボタンを長押しした際のオートリピートのためのタイマー
    const timeoutRef = { current: null };
    const intervalRef = { current: null };

    // タイマーをクリアする関数
    const clearTimers = () => {
      if (timeoutRef.current) clearTimeout(timeoutRef.current);
      if (intervalRef.current) clearInterval(intervalRef.current);
      timeoutRef.current = intervalRef.current = null;
    };

    // スピンボタン押下時に即1回実行し、500ミリ秒後から100ミリ秒ごとに連続実行
    button.addEventListener('mousedown', (e) => {
      e.preventDefault();
      updateFunction();
      timeoutRef.current = setTimeout(() => {
        intervalRef.current = setInterval(updateFunction, 100);
      }, 500);
    });

    // スピンボタン押下をやめたときと、カーソルがスピンボタンから離れたときにタイマークリア
    button.addEventListener('mouseup', clearTimers);
    button.addEventListener('mouseleave', clearTimers);

    return button;
  };

  // 追加画面か編集画面が表示されたときに発火
  kintone.events.on(['app.record.create.show', 'app.record.edit.show'], async (event) => {
    // 要素IDがspaceのスペースフィールドの要素を取得
    spaceElement = kintone.app.record.getSpaceElement('space');
    // もうスペースフィールドに子要素が存在する場合は重複実行を防ぐため処理中止
    if (spaceElement.children.length > 0) return event;

    // スピンボタンのCSSを一度だけ追加
    if (!document.getElementById('spinner-style')) {
      const style = document.createElement('style');
      style.id = 'spinner-style';
      style.textContent = `
        .custom-text-with-spinner{margin-left:8px}
        [class*="__group__input-form__input-outer"]{position:relative!important}
        [class*="__group__input-form__input-outer__input"]{padding-right:24px!important}
        .custom-spinner-buttons{
          position:absolute;right:1px;top:1px;bottom:1px;
          display:flex;flex-direction:column;width:18px
        }
        .custom-spinner-button{
          flex:1;background:#f7f9fa;border:1px solid #e3e7e8;border-right:none;
          cursor:pointer;display:flex;align-items:center;justify-content:center;
          font-size:8px;color:#3498db;padding:0;margin:0
        }
        .custom-spinner-button:hover{background:#e8ebed}
        .custom-spinner-button:active{background:#d8dcde}
        .custom-spinner-button-up{border-bottom:none;border-radius:0}
        .custom-spinner-button-down{border-radius:0}
      `;
      document.head.appendChild(style);
    }

    // 数値フィールドの設定情報を取得
    const resp = await kintone.api(kintone.api.url('/k/v1/app/form/fields', true),
      'GET', { app: kintone.app.getId() });

    // 数値フィールドの存在確認
    fieldExists = Object.keys(resp.properties).some(
      key => resp.properties[key].code === fieldCode
    );

    let initialValue = '';
    if (fieldExists) {
      const field = resp.properties[fieldCode];
      // 数値フィールドの最小値と最大値を取得
      if (field.minValue) minValue = parseFloat(field.minValue);
      if (field.maxValue) maxValue = parseFloat(field.maxValue);

      // 追加画面では数値フィールドの初期値をTextコンポーネントの初期値として流用
      if (event.type === 'app.record.create.show' && field.defaultValue) {
        initialValue = field.defaultValue;
      }
      // 編集画面では編集中のレコードの数値フィールドの値をTextコンポーネントに入力
      if (event.type === 'app.record.edit.show' && event.record[fieldCode]?.value) {
        initialValue = event.record[fieldCode].value;
      }

      // 数値フィールドを非表示にする
      kintone.app.record.setFieldShown(fieldCode, false);
    }

    // Textコンポーネントに最小値と最大値に応じたプレースホルダーを生成
    let placeholder = '';
    if (minValue !== -Infinity && maxValue !== Infinity) {
      placeholder = `${minValue}以上${maxValue}以下`;
    } else if (minValue !== -Infinity) {
      placeholder = `${minValue}以上`;
    } else if (maxValue !== Infinity) {
      placeholder = `${maxValue}以下`;
    }

    // スペースフィールドにTextコンポーネントを追加
    text = new Kuc.Text({
      label: fieldCode,
      placeholder: placeholder,
      textAlign: 'right',
      value: initialValue,
      className: 'custom-text-with-spinner',
      suffix: ''
    });
    spaceElement.appendChild(text);

    // Textコンポーネントの値変更時のバリデーション
    text.addEventListener('change', event => {
      let value = event.detail.value;
      // 値が空の場合はエラーをクリア
      if (!value) {
        text.error = '';
        return;
      }

      // 値が最小値未満または最大値超過の場合はエラーを表示
      const numValue = parseFloat(value);
      if (numValue < minValue) {
        text.error = `${minValue}以上の値を入力してください。`;
        return;
      }
      if (numValue > maxValue) {
        text.error = `${maxValue}以下の値を入力してください。`;
        return;
      }

      // 値が範囲内の場合はエラーをクリア
      text.error = '';
    });

    // DOM描画完了後にバリデーションとスピンボタンを設定
    requestAnimationFrame(() => {
      inputElement = text.querySelector('input');
      if (!inputElement) return;

      // 重複設定を防ぐためのフラグチェック
      if (inputElement.dataset.listenerAttached) return;
      inputElement.dataset.listenerAttached = true;

      // IME入力開始時にフラグを立てる
      let isComposing = false;
      inputElement.addEventListener('compositionstart', () => {
        isComposing = true;
      });

      // IME入力終了時にフラグを下ろしてバリデーションを実行
      inputElement.addEventListener('compositionend', () => {
        isComposing = false;
        requestAnimationFrame(() => processInput(inputElement));
      });

      // IMEを使わない入力時のバリデーション
      inputElement.addEventListener('input', () => {
        if (!isComposing) processInput(inputElement);
      });

      // Textコンポーネントからフォーカスが外れたとき、値の末尾に小数点があれば削除
      inputElement.addEventListener('blur', () => {
        let value = inputElement.value;
        if (value.endsWith('.')) {
          value = value.substring(0, value.length - 1);
          inputElement.value = value;
          text.value = value;
          inputElement.dispatchEvent(
            new Event('change', { bubbles: true })
          );
        }
      });

      // Textコンポーネントのinput要素を内包するコンテナを取得
      const inputOuter = text.querySelector(
        '[class*="__group__input-form__input-outer"]'
      );
      if (!inputOuter) return;

      // スピンボタンを格納するコンテナを生成
      const buttonsContainer = document.createElement('div');
      buttonsContainer.className = 'custom-spinner-buttons';

      // ▲ボタン(インクリメント)と▼ボタン(デクリメント)を生成してコンテナに追加
      const updateUpValue = createUpdateValueFunction(1, initialValue);
      const updateDownValue =
        createUpdateValueFunction(-1, initialValue);
      buttonsContainer.appendChild(
        createSpinButton('', 'custom-spinner-button-up', updateUpValue)
      );
      buttonsContainer.appendChild(
        createSpinButton('', 'custom-spinner-button-down', updateDownValue)
      );

      // スピンボタンをinput要素の右側に配置
      inputOuter.appendChild(buttonsContainer);
    });

    return event;
  });

  // 追加画面か編集画面で保存ボタンを押したら、Textコンポーネントの値を数値フィールドに転記
  kintone.events.on(['app.record.create.submit', 'app.record.edit.submit'], (event) => {
    if (fieldExists && text) event.record[fieldCode].value = text.value;
    return event;
  });
})();

/k/v1/app/form/fields.json で、数値フィールドの最小値と最大値を取得し
値の制限を動的にTextコンポーネントのプレースホルダーに表示する。

スピンボタンを押すと、値が1増加または減少する。
今回のケースでは、最大値100なので▲ボタンを押しても値が100を超過せず
最小値0なので▼ボタンを押しても値が0未満にならない。

Animation.gif

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?