SCALE
SCALE Build Hub
機能集
UI部品 React Component

インライン編集セル(EditableCell)

出典: SCALE Finance
実装時間
30〜90分
難度
簡単
価格
単体¥2,000 / 5箇所組込み¥10,000

依存パッケージ

reactlucide-react(アイコン用・必須ではない)

ファイル

components/finance/EditableCell.tsx

EditableCell(インライン編集セル)

:LiTarget: 用途

クリックすると編集モードに入り、Enter で確定 / Esc で取消 / blur(フォーカス外し)でも確定する万能セル。

:LiCode: コード(コピペ用・full code)

'use client';

import { useState, useRef, useEffect, useCallback } from 'react';

type Props = {
  value: string | number | null | undefined;
  onSave: (newValue: string | number) => void;
  type?: 'text' | 'number' | 'date';
  options?: readonly string[];
  className?: string;
  inputClassName?: string;
  format?: (v: string | number) => string;
  placeholder?: string;
  align?: 'left' | 'right' | 'center';
  ariaLabel?: string;
};

export default function EditableCell({
  value, onSave, type = 'text', options, className = '',
  inputClassName = '', format, placeholder = '—', align = 'left', ariaLabel,
}: Props) {
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState<string>(String(value ?? ''));
  const inputRef = useRef<HTMLInputElement | HTMLSelectElement | null>(null);

  useEffect(() => { setDraft(String(value ?? '')); }, [value]);
  useEffect(() => {
    if (editing && inputRef.current) {
      inputRef.current.focus();
      if (inputRef.current instanceof HTMLInputElement) inputRef.current.select();
    }
  }, [editing]);

  const commit = useCallback(() => {
    setEditing(false);
    if (type === 'number') {
      const cleaned = draft.replace(/[¥,\s]/g, '');
      const n = parseFloat(cleaned);
      onSave(isNaN(n) ? 0 : n);
    } else { onSave(draft); }
  }, [draft, type, onSave]);

  const cancel = useCallback(() => {
    setEditing(false);
    setDraft(String(value ?? ''));
  }, [value]);

  const alignCls = align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left';

  if (!editing) {
    const display = value === '' || value == null ? placeholder : format ? format(value) : String(value);
    return (
      <span role="button" tabIndex={0} aria-label={ariaLabel ?? '編集'}
        className={`inline-block cursor-pointer hover:bg-bg3 rounded px-1.5 py-0.5 transition-colors ${alignCls} ${className}`}
        onClick={() => setEditing(true)}
        onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setEditing(true); } }}
        title="クリックで編集">
        {display}
      </span>
    );
  }

  const baseInputCls = `bg-bg3 border border-blue-500 rounded px-1.5 py-0.5 text-text outline-none ${alignCls} ${inputClassName}`;

  if (options) {
    return (
      <select ref={(el) => { inputRef.current = el; }} value={draft}
        onChange={(e) => setDraft(e.target.value)} onBlur={commit}
        onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') cancel(); }}
        className={baseInputCls}>
        {options.map((opt) => (<option key={opt} value={opt}>{opt}</option>))}
      </select>
    );
  }

  return (
    <input ref={(el) => { inputRef.current = el; }}
      type={type === 'number' ? 'text' : type}
      inputMode={type === 'number' ? 'decimal' : undefined}
      value={draft}
      onChange={(e) => setDraft(e.target.value)} onBlur={commit}
      onKeyDown={(e) => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') cancel(); }}
      className={baseInputCls} placeholder={placeholder} />
  );
}

:LiHandPointer: 使用例

1. 単純なテキスト編集

<EditableCell value={user.name} onSave={(v) => updateUser({ name: String(v) })} />

2. 数値 + 金額フォーマット

<EditableCell
  value={item.price}
  onSave={(v) => updateItem({ price: Number(v) })}
  type="number"
  align="right"
  format={(v) => `¥${Number(v).toLocaleString()}`}
/>

3. プルダウン

<EditableCell
  value={item.status}
  onSave={(v) => updateItem({ status: String(v) })}
  options={['提出済', '未提出', '受領済', '支払済']}
/>

4. 日付

<EditableCell value={item.date} onSave={(v) => updateItem({ date: String(v) })} type="date" />

:LiAlertCircle: 注意事項