EditableCell(インライン編集セル)
:LiTarget: 用途
クリックすると編集モードに入り、Enter で確定 / Esc で取消 / blur(フォーカス外し)でも確定する万能セル。
- text / number / date / select に対応
- 表示時のフォーマッタ(金額に¥付与など)
- placeholder 対応
- align (left / right / center)
: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: 注意事項
'use client'必須(React の useState 使用)- Tailwind の bg-bg3 等が前提。生 CSS 環境では
inputClassNameで上書き - 親コンポーネント側で onSave のロジック実装が必要(localStorage / API call)