Excel の帳票を、毎日データ入力をしている人の前に出してみると、よく次のようなことが起きます。最初の入力欄をクリックしても何も起きません。スプレッドシートはダブルクリックで編集を始めるからです。名前を入れて Enter を押すと、カーソルは真下のラベルに移ります。Tab を押すと保護されたセルに入ってしまいます。そのうち列の境界をうっかりドラッグして、印刷物に合わせて作った帳票の幅が 40 ピクセル狭くなります。
どれもバグではありません。スプレッドシートとして正しく動いているだけです。問題は、業務で「スプレッドシート」と呼ばれているものの多くが、実際には下にグリッドがあるだけのフォームだということです。入力する人が期待しているのはフォームの動きです。
ReoGrid Web v1.6 では、まさにこの用途のためのワークシート設定が加わりました。この記事ではそれを使って経費精算書を一枚作ります。リリース記事の短い紹介では触れていない部分、つまりタブ順が実際にどう決まるか、独自の順序の書き方、入力された値の取り出し方(数値で返ってくる日付も含めて)まで扱います。
フォームモードは無料の Lite を含む両エディションで使えます。作例は入力規則と
SUM数式をコードから書くので@reogrid/proを import していますが、同じフォームを Lite で作る方法はエディションの節で紹介します。
フォームモードで変わること
ワークシートの設定 4 つと、セル単位のヒントが 1 つ。冒頭の不便を 1 つずつ解消します。
| 設定 | スプレッドシートの既定 | フォームモード |
|---|---|---|
ws.clickToEdit = true | ダブルクリックで編集 | 1 クリックで編集開始、キャレットは末尾。入力セルの上では I ビームカーソル |
ws.formNavigation = true | Enter で下へ、Tab で右へ | Enter / Tab で次の入力セルへ移り、そのまま入力できる状態で開く |
ws.layoutLocked = true | ヘッダーのドラッグ・ダブルクリック・並べ替えができる | 列幅と行高は設計したまま |
ws.setCellPlaceholder(r, c, text) | 空のセルはただの空欄 | 入力されるまで薄い色のヒントを表示 |
ws.setFormNavigator(fn) | — | 既定の移動順を独自の関数に置き換える |
何が「入力セル」かは、新しく定義するものではありません。セル保護にすでにある考え方をそのまま使います。ws.protected = true にするとすべてのセルがロックされ、ロックを解除したセルだけがフォームの入力欄になります。入力欄を一度指定すれば、フォームモードの他の機能はすべてそれに従います。
作例:経費精算書
1 行に 2 項目ずつのヘッダー、5 行の明細表、合計、備考欄を持つ帳票です。全コードは次のとおりです。
import { createReogrid } from '@reogrid/pro';
const grid = createReogrid({ workspace: '#grid', licenseKey: 'YOUR_LICENSE_KEY' });
const ws = grid.worksheet;
ws.suspendRender();
ws.setGridSize(16, 6); // A〜F 列。帳票に既定の 40 行は要らない
ws.showGridLines = false; // 罫線は帳票として引いたところだけ
// ── レイアウト ─────────────────────────────────────────────────────────
[16, 100, 130, 220, 120, 16].forEach((w, c) => ws.column(c).setWidth(w));
const label = { backgroundColor: '#f1f5f9' } as const;
const head = { bold: true, backgroundColor: '#1e3a5f', color: '#ffffff', textAlign: 'center' } as const;
ws.range('B2:E2').merge();
ws.cell('B2').setValue('経費精算書').setStyle({ ...head, fontSize: 14, verticalAlign: 'middle' });
ws.row(1).setHeight(34);
ws.cell('B4').setValue('氏名').setStyle(label);
ws.cell('D4').setValue('部署').setStyle(label);
ws.cell('B5').setValue('申請日').setStyle(label);
ws.cell('D5').setValue('プロジェクト').setStyle(label);
['日付', '費目', '内容', '金額'].forEach((h, i) =>
ws.cell(6, 1 + i).setValue(h).setStyle(head));
ws.cell('D13').setValue('合計').setStyle({ ...label, bold: true, textAlign: 'right' });
ws.cell('E13').setValue('=SUM(E8:E12)').setStyle({ bold: true });
ws.range('E8:E13').setFormat('#,##0');
ws.cell('B15').setValue('備考').setStyle(label);
ws.range('C15:E15').merge();
for (const block of ['B4:E5', 'B7:E13', 'B15:E15']) {
ws.range(block).border({ style: 'solid', color: '#94a3b8' });
}
ws.resumeRender();
// ── 1. シートを保護し、入力欄だけロックを外す ──────────────────────────
ws.protected = true;
for (const field of ['C4', 'E4', 'C5', 'E5', 'B8:E12', 'C15:E15']) {
ws.range(field).setLock('unlocked');
}
// ── 2. フォームとしての振る舞い ────────────────────────────────────────
ws.layoutLocked = true; // 利用者はサイズ変更も並べ替えもできない
ws.clickToEdit = true; // 1 クリックで入力開始
ws.formNavigation = true; // Enter / Tab で入力欄を順にたどる
// ── 3. ヒント ― (row, column) は 0 始まり。(3, 2) が C4 ────────────────
ws.setCellPlaceholder(3, 2, '例: 山田 太郎');
ws.setCellPlaceholder(4, 2, 'YYYY-MM-DD');
ws.setCellPlaceholder(4, 4, '例: PRJ-204');
ws.setCellPlaceholder(7, 1, 'YYYY-MM-DD');
ws.setCellPlaceholder(7, 3, '何の費用か');
ws.setCellPlaceholder(14, 2, '任意');
// ── 4. 入力規則 ― ドロップダウン 2 つ、正しい日付、正の金額 ────────────
ws.range('E4').setValidation({
type: 'list', options: ['営業部', '開発部', 'サポート部', '管理部'],
});
ws.range('C8:C12').setValidation({
type: 'list', options: ['交通費', '会議費', '宿泊費', '消耗品費', 'その他'],
});
for (const dates of ['C5', 'B8:B12']) {
ws.range(dates).setValidation({
type: 'date', operator: 'lessThanOrEqual', value1: '=TODAY()',
errorMessage: '今日以前の日付を入力してください。',
});
}
ws.range('E8:E12').setValidation({
type: 'decimal', operator: 'greaterThan', value1: 0,
errorMessage: '金額は正の数で入力してください。',
});
// 最初の入力欄を選んだ状態で始める
ws.selection.moveTo('C4');
grid.focus();
上の画像は、マウスを一度も使わずに作りました。C4 から始めて、氏名を入力して Enter。カーソルは部署に移ります。部署はリストの欄なので、↓ でリストが開き、もう一度 ↓、Enter で「開発部」を選ぶと次の欄へ進みます。申請日を入力して Enter。プロジェクトは任意なので、空のまま Enter を押せば飛ばせます。あとは明細の 1 行目を左から右へ入力し、そのまま次の行へ進みます。
ここから先は、それぞれの部分が何をしているかを順に見ていきます。
入力欄は「ロックを外したセル」
ws.protected = true;
ws.range('B8:E12').setLock('unlocked');
帳票の入力欄を宣言しているのはここだけです。保護によって、ラベル・見出し行・合計 の数式には入力できなくなります。フォームの移動も同じロック状態を見て、Enter の行き先を決めます。clickToEdit も同じで、保護セルをクリックしても選択されるだけです。
2 つの範囲には補足があります。
B8:E12はブロック全体のロックを外しています。中のセルはすべて入力欄になり、読み順にたどられます。C15:E15は結合セルです。結合ブロックは左上のアンカーで1 つの停止位置として扱われるので、備考欄は 3 つではなく 1 つの入力欄になります。
保護が守るのはセルの中身で、形までは守りません。そのための設定が次の layoutLocked です。
layoutLocked ― 帳票は描いた形のまま
ws.layoutLocked = true;
利用者がうっかりシートの形を変えてしまう操作を、この 1 行でまとめて止めます。ヘッダーのドラッグによる列幅・行高の変更、それを誘うリサイズカーソル、ダブルクリックでの自動調整、行・列単位のドラッグによる並べ替えがすべて無効になります。
止めるのは利用者の操作だけです。API からのサイズ指定はこれまでどおり効くので、テンプレートを組むコードや、画面幅に応じた再計算、「ワイド表示」ボタンなどはそのまま動きます。
ws.column(3).width = 280; // layoutLocked 中でも反映される
帳票を印刷したり PDF に出力したりする場合、画面と紙の見た目を揃えておけるのもこの設定のおかげです。出力側の話は PDF 請求書の記事をご覧ください。
clickToEdit ― 1 クリックで入力
ws.clickToEdit = true;
編集できるセルを普通に左クリックすると、その場でエディタが開きます。キャレットは既存の文字列の末尾に置かれるので、誤字の修正にもそのまま使えます。入力セルの上ではマウスポインタが I ビームになり、保護セルの上では通常のポインタのままなので、クリックする前から入力欄の位置がわかります。
いくつかの操作は意図的に変えていません。保護セルは開きません。ドラッグや修飾キー付きのクリックは、これまでどおり範囲選択です。チェックボックスのように文字の代わりに独自の表示を持つセルタイプは、それぞれのクリック動作を保ちます。チェックボックスはエディタを開かずにオン・オフが切り替わります。
formNavigation ― Enter と Tab で入力欄を順にたどる
ws.formNavigation = true;
入力欄の編集中に Enter か Tab を押すと、値を確定して次の入力セルへ移り、そこでエディタを開きます。既存の文字列は全選択された状態なので、そのまま上書き入力できます。Shift を押しながらだと逆方向に戻ります。
既定の順序は、編集できるセルを読み順にたどるスキャンです。経費精算書では次の順になります。
C4 → E4 → C5 → E5 → B8 → C8 → D8 → E8 → B9 → … → E12 → C15
スキャンの動きは次のとおりです。
- 保護セルは飛ばすので、C4 と E4 の間にある「部署」のラベルで止まることはありません。
- 行の末尾で折り返すので、ある行の最後の欄から次の行の最初の欄へ進みます。E4 から C5 へ、各明細行の金額から次の行の日付へ進めるのはこのためです。
- 非表示の行と列は飛ばすので、任意入力のセクションを隠せば、タブ順もそこを飛ばします。
- 結合ブロックは 1 か所として、アンカーで止まります(備考欄)。
- フォームの末尾では、Enter で値を確定してその場に留まります。先頭に戻ることはありません。
他の機能との関係で、知っておくとよい点が 3 つあります。
入力規則のチェックが先に動きます。 Enter は、移動する前に値を確定します。金額に -50 と入れるなどして stop のルールに弾かれると、エラーが表示され、エディタはそのセルで開いたまま、次の欄へは進みません。不正な値のまま先へ進むことはできません。ルールそのものはデータ検証の記事をご覧ください。
移動は編集中だけ働きます。 選択されているだけのセルで Enter を押すと、移動ではなく編集が始まります。コードから入力欄を選択した場合も、利用者はそのまま入力を始められます。
IME の Enter は IME のものです。 日本語入力の変換を確定する Enter は IME が処理し、フォームの移動は起きません。もう一度 Enter を押すと値が確定し、次の欄へ進みます。氏名に「山田太郎」と入力するときは、Enter を 2 回押すことになります。変換確定のつもりの Enter で欄が勝手に進むことはありません。
プレースホルダー ― データにならないヒント
ws.setCellPlaceholder(4, 2, 'YYYY-MM-DD');
プレースホルダーは、セルが空の間だけ薄い色で表示され、入力が始まると消えます。HTML の input の placeholder 属性と同じです。灰色の文字をセルの値として入れておく方法もありますが、それだと送信にも印刷にもエクスポートにも含まれてしまいます。本物のプレースホルダーは表示専用です。
- セルの値には含まれません。 ヒントが見えている間も
ws.cell('C5').valueは''です。 - PDF・印刷・xlsx・JSON には一切出ません。
- 数値として右揃えにはならず、隣のセルにはみ出すこともありません。
- 行や列を挿入・削除すると、コメントと同じようにセルと一緒に移動します。
残りの API もシンプルです。
ws.getCellPlaceholder(4, 2); // 'YYYY-MM-DD' | null
ws.getCellPlaceholderEntries(); // [{ row, column, text }, …]
ws.setCellPlaceholder(4, 2, ''); // 1 つ消す
ws.clearCellPlaceholders(); // すべて消す
作例では、2 つのリストの欄にはプレースホルダーを付けていません。リストの欄はドロップダウンの矢印ですでに目印が付いているので、ヒントを重ねても煩わしいだけです。
ドロップダウンもキーボードで
1 か所でもマウスが必要な欄があると、キーボード中心のフォームとはいえません。そこで、入力規則のリストはキーボードだけで操作できるようになっています。
| キー | 場面 | 動作 |
|---|---|---|
| ↓ | リストの欄を編集中 | ドロップダウンを開く |
| Alt+↓ | リストの欄を選択中 | ドロップダウンを開く(Excel と同じショートカット) |
| ↑ / ↓ | リストが開いている | 選択肢を移動 |
| Enter / Tab | リストが開いている | ハイライト中の選択肢を選ぶ |
| Escape | リストが開いている | 選ばずに閉じる |
リストはセルの現在値の位置で開きます。空のセルなら最初の選択肢の位置です。費目を選ぶ操作は、たいてい ↓ ↓ Enter で済みます。
フォームでは、リストの欄にはエディタが開いた状態で到着するので、使うのは主に ↓ です。この方法で開いたリストの選択はエディタを経由して確定されます。選択肢を入力して Enter を押したのとまったく同じ扱いなので、入力規則のチェックも通り、フォームの移動で次の欄へ進みます。作例では、C8 で「交通費」を選ぶと D8 に移り、そのまま内容を入力できます。選択しているだけのセルで Alt+↓ を使った場合は Excel と同じ動きで、選んだ値を書き込み、選択位置はそのままです。
独自のタブ順
読み順のスキャンは、たいていのフォームで正しい順序になりますが、そうでない場合もあります。2 列のレイアウトを左の列から縦に埋めたいこともあるでしょう。明細行が、ほとんどの人が使う行数より多いこともあります。setFormNavigator を使うと、スキャンを自分で書いた関数に置き換えられます。
type Cell = { row: number; column: number };
const at = (row: number, column: number): Cell => ({ row, column });
// 入力欄を順に並べる:ヘッダー、明細を 1 行ずつ、最後に備考
const ORDER: Cell[] = [at(3, 2), at(3, 4), at(4, 2), at(4, 4)]; // C4 E4 C5 E5
for (let r = 7; r <= 11; r++) {
for (let c = 1; c <= 4; c++) ORDER.push(at(r, c)); // B8:E12
}
const NOTES = at(14, 2); // C15
ORDER.push(NOTES);
const LINE_ROWS = { first: 7, last: 11 };
ws.setFormNavigator((from, direction) => {
// 明細の日付が空のまま Enter = 「明細はここまで」→ 備考へ直行
const onLineDate =
from.column === 1 && from.row >= LINE_ROWS.first && from.row <= LINE_ROWS.last;
if (direction === 1 && onLineDate && ws.cell(from.row, 1).value === '') {
return NOTES;
}
const i = ORDER.findIndex((f) => f.row === from.row && f.column === from.column);
return ORDER[i + direction] ?? null; // null = ここで止まる
});
ナビゲーターには、確定したばかりのセルと方向(1 が前、-1 が後ろ)が渡されます。戻り値は次に開くセルで、null を返すと値を確定してその場に留まります。確定の後に呼ばれるので、利用者がいま入力した値を見て判断できます。「日付が空なら明細は終わり」というルールはそれを使っています。領収書が 2 枚だけの人は、3 行目の空の日付で Enter を押せば備考欄へ直行でき、空のセル 12 個を Tab で通り抜ける必要はありません。
ナビゲーターを書くときの注意が 2 つあります。
- ナビゲーターを設定すると、スキャンは完全に置き換わります。 その順序だけが使われるので、
ORDERには例外の欄だけでなくすべての入力欄を並べています。既定のスキャンに戻すにはws.setFormNavigator(null)を呼びます。 - ナビゲーターの中から
ws.nextInputCell()を呼ばないでください。 このメソッドは「ここで Enter を押したらどこへ行くか」を返しますが、ナビゲーターが設定されていると、その答えはあなたのナビゲーターから来ます。中から呼ぶと無限再帰になります。
外から呼ぶ分には、まさにその問いに答えてくれます。たとえば「次:部署」のような案内を出すのに使えます。
ws.nextInputCell(3, 2, 1); // → { row: 3, column: 4 }(E4)。末尾なら null
入力値を取り出す
帳票が埋まったら、アプリはそれをオブジェクトとして受け取りたいはずです。入力欄はどれもセルなので、読み取りはセルの値をループで拾うだけですが、注意点が 2 つあります。
const text = (a1: string) => ws.cell(a1).value.trim();
// 日付は Excel のシリアル値で返ってくる(後述)
const isoDate = (a1: string) => {
const serial = Number(text(a1));
if (!text(a1) || !Number.isFinite(serial)) return text(a1);
return new Date(Date.UTC(1899, 11, 30) + serial * 86_400_000).toISOString().slice(0, 10);
};
function readClaim() {
const lines = [];
for (let r = 8; r <= 12; r++) {
if (!text(`B${r}`) && !text(`D${r}`) && !text(`E${r}`)) continue; // 未使用の行
lines.push({
date: isoDate(`B${r}`),
category: text(`C${r}`),
description: text(`D${r}`),
amount: Number(text(`E${r}`)),
});
}
return {
name: text('C4'),
department: text('E4'),
claimDate: isoDate('C5'),
projectCode: text('E5'),
lines,
notes: text('C15'),
};
}
日付は数値で返ってきます。 表示形式を設定していないセルに 2026-09-18 と入力すると、グリッドは Excel と同じ処理をします。日付のシリアル値 46283 を保存し、日付の表示形式を適用して、セルには日付として表示します。cell.value が返すのは保存された入力値、つまりシリアル値なので、isoDate で文字列に戻しています。Excel から日付を貼り付けたときに現れるのと同じ値です。この変換を安全に行えるのは、これらのセルに日付の入力規則を付けているからです。日付でないものは最初から入りません。
必須項目のチェックはアプリ側で行います。 入力規則がチェックするのは、入力された値です。列に付けたルールが入力途中の行とぶつからないように、既定では空のセルを通します(ignoreBlank: true)。「氏名は必須」は送信時のチェックになります。空の欄が見つかったら、利用者をその欄に戻すのが親切です。
const REQUIRED = ['C4', 'E4', 'C5'];
submitButton.addEventListener('click', () => {
const missing = REQUIRED.find((a1) => text(a1) === '');
if (missing) {
ws.selection.moveTo(missing); // 空の欄に戻し……
grid.focus(); // ……キーボードもグリッドへ。そのまま入力できる
return;
}
void fetch('/api/expense-claims', { method: 'POST', body: JSON.stringify(readClaim()) });
});
大事なのは grid.focus() のほうです。送信ボタンをクリックした時点でキーボードフォーカスはボタンに移っています。グリッドに戻さないと、利用者が次に打ったキーはどこにも届きません。
保存されない設定は、読み込みのたびに付け直す
3 つのスイッチ(layoutLocked・clickToEdit・formNavigation)、ナビゲーター、すべてのプレースホルダーは画面上の状態で、xlsx にも ReoGrid JSON にも書き出されません。しかも、読み込み時の扱いが項目によって違います。loadJson() や reset() はセルの内容と一緒にプレースホルダーを消しますが、スイッチとナビゲーターはワークシートオブジェクトに元の値のまま残ります。そのため、同じページ内で読み込み直すと、Enter による移動は効いているのにヒントだけが消えた状態になります。一方、ページを開き直せばどれも設定されていない状態から始まります。
どれが残ったかを追いかけるより、全部を 1 つの関数にまとめて、読み込みのたびに実行するほうが確実です。2 回実行しても問題ありません。
function applyFormMode(ws: typeof grid.worksheet) {
ws.layoutLocked = true;
ws.clickToEdit = true;
ws.formNavigation = true;
ws.setCellPlaceholder(3, 2, '例: 山田 太郎');
ws.setCellPlaceholder(4, 2, 'YYYY-MM-DD');
// ……残りのヒントと、使うなら setFormNavigator()
}
入力途中の精算書を保存して、あとで復元する処理は次のようになります。
const draft = grid.toJson(); // 値・スタイル・入力規則・保護・ロック解除セル
// ……あとで
grid.loadJson(draft);
applyFormMode(grid.worksheet); // ……ただしヒントとスイッチは付け直す
ReoGrid JSON には保護フラグ、ロックを外したセル、入力規則が含まれます。入力欄は往復で残るので、付け直す必要があるのは振る舞いだけです。読み込み後は、以前に取得した参照を使い続けず、grid.worksheet を取り直してください。複数シートのドキュメントは保存時のアクティブシートを復元するので、手元の参照と同じシートとは限りません。
Lite はどこまで、Pro はどこから
フォームモード自体は無料です。作例が Pro を必要としているのは、フォームモードとは関係のない 2 か所のためです。
| 機能 | Lite(無料) | Pro |
|---|---|---|
layoutLocked・clickToEdit・formNavigation・setFormNavigator | ✅ | ✅ |
プレースホルダー(setCellPlaceholder など) | ✅ | ✅ |
シート保護とロック解除(protected・setLock) | ✅ | ✅ |
| リストの入力規則をキーボードで操作(↓ / Alt+↓) | ✅ | ✅ |
| xlsx や JSON から読み込んだ入力規則の強制 | ✅ | ✅ |
入力規則をコードで作成(setValidation) | — | ✅ |
組み込み関数(合計セルの SUM) | — | ✅ |
xlsx エクスポート(saveAsXlsx) | — | ✅ |
つまり、Lite で同じ帳票を作る方法がはっきりあります。Excel で作ることです。レイアウトを組み、「データ → データの入力規則」でドロップダウンと日付のルールを付けて xlsx で保存し、残りは ReoGrid Web Lite に任せます。
import { createReogrid } from '@reogrid/lite';
const grid = createReogrid('#grid');
await grid.loadFromUrl('/templates/expense-claim.xlsx');
const ws = grid.worksheet;
ws.protected = true; // xlsx のシート保護は読み込まれないので、
for (const field of ['C4', 'E4', 'C5', 'E5', 'B8:E12', 'C15:E15']) {
ws.range(field).setLock('unlocked'); // 入力欄はコードで指定する
}
applyFormMode(ws);
入力規則はファイルと一緒に読み込まれ、Lite でも強制されます。ドロップダウンもキーボードでの選択もそのまま使えます。注意点は 2 つです。シート保護とセルのロック状態は xlsx から読み込まれないので、上のように入力欄はコードで指定します。また、Lite は四則演算は評価できますが名前付きの関数を持たないので、テンプレートの合計は =SUM(E8:E12) ではなく =E8+E9+E10+E11+E12 と書いておきます。この程度の帳票なら、Lite の上限である 100 行 × 26 列にも十分収まります。
まとめ
スプレッドシートがフォームになるのは、入力する人が気づく場面でスプレッドシートらしい動きをやめたときです。どのセルが入力欄かは保護が決めます。layoutLocked が形を固定し、clickToEdit が 1 クリックで入力欄を開き、formNavigation が Enter と Tab で入力欄を順に移動します。不正な値なら入力規則がそこで止めます。プレースホルダーはデータにならずに入力形式を示し、リストのドロップダウンもキーボードで開けるので、マウスが必要な欄は残りません。読み順が正しい順序でない場合は、setFormNavigator に任せます。
フォームモードのデモで試してみてください。ツールバーで各スイッチを切り替えて、違いを確かめられます。API の全体はフォームモードのドキュメントにあります。入力欄に不正な値を入れないためのルールはデータ検証の記事を、入力済みの帳票を PDF にする方法は PDF 請求書の記事をご覧ください。