<input type="file">要素を使ったとき、残念なのは見た目です。本稿では、この見映えを改めます。独自にCSSを定めるのも構いません。けれど、ここでは手っ取り早くMUI(本稿執筆時の最新はv5.6.4)を用いることにしましょう。でき上がるファイル操作のインタフェース作例がつぎのサンプル001です。
サンプル001■React + TypeScript: Change style of <input type="file"> with MUI
この記事は全3回のチュートリアルシリーズの第3回(最終回)です。3回とおして前掲サンプル001ような作例をつくります。<input type="file">要素を共通に扱うものの、それぞれテーマは別です。興味に応じて、解説を個別に読んでいただいても構いません。
チュートリアルシリーズ
- 「React + TypeScript: <input type="file">要素で選択した画像ファイルのイメージをページに表示する」
- 「React + TypeScript: 動的に読み込んだ画像ファイルのイメージを縦横比は変えずに一定サイズに収める」
- 「React + TypeScript: <input type="file">要素の見映えのカスタマイズとファイルのドロップによる選択 ー MUIを用いて」(本稿)
本稿のコード例解説は、前回の「React + TypeScript: 動的に読み込んだ画像ファイルのイメージを縦横比は変えずに一定サイズに収める」でつくったCodeSandbox作例に手を加えていくかたちで進めます。サンプル001をはじめからつくりたい方は、前の回の記事をお読みください。
どういう方針で進めるか
はじめに確認しておきたいのは、<input type="file">要素をMUIのInputコンポーネントに置き替えれば済むというお題ではありません。Inputコンポーネントにはプロパティにtypeがあり、値としてfileも指定できます(「<input>: The Input (Form Input) element」参照)。けれど、このとき内部的に用いられているのは、標準の<input type="file">要素です。その中身のスタイルをMUIが触ることは、おそらくできないのでしょう。したがって、Inputコンポーネントに差し替えても、おおもとの<input>要素に当てられたMUIの基本的なCSSが反映されるだけで、貧弱な見た目は変わりません。
ではどうするかというと、MDN「ウェブアプリケーションからのファイルの使用」が紹介するのはつぎのような手法です。<input type="file">要素は隠して、いわば影武者を立てます。この時点で、わりと面倒そうという予想はつくでしょう。それにしても、「見た目の悪い<input>要素」と一蹴される標準の実装は、なんとかしてほしいものです。
見た目の悪い
<input>要素を非表示にし、独自のインターフェイスでファイル選択を開き、ユーザーが選択したファイルを表示することができます。input要素のスタイルをdisplay: noneとし、その上でclick()メソッドを<input>に対して呼び出すことで実現できます。
(「click() メソッドを使用して非表示の input 要素を使用する」)
MUIはあらかじめインストールしておいてください。
非表示にした<input type="file">要素のファイル選択ダイアログを新たなボタンクのリックで開く
まずは、<input type="file">要素を非表示にします。替わりに加えるのがMUIのButtonコンポーネントです。
export const styles = {
inputFile: {
display: 'none'
},
};
import Button from '@mui/material/Button';
export const FileInput: FC = () => {
// const { handleFiles, imageContainerRef } = useHooks();
const { handleFiles, imageContainerRef, inputFileRef } = useHooks();
return (
<div>
{/* <input type="file" accept="image/*" onChange={handleFiles} /> */}
<input
type="file"
ref={inputFileRef}
accept="image/*"
onChange={handleFiles}
style={{ ...styles.inputFile }}
/>
<Button variant="contained">ファイルを選択</Button>
</div>
);
};
隠した<input type="file">要素には、Buttonコンポーネントから操作するため、カスタムフック(useHooks)がつくったrefオブジェクト(inputFileRef)を要素のrefプロパティに与えました。
export const useHooks = () => {
const inputFileRef = useRef<HTMLInputElement>(null);
// return { handleFiles, imageContainerRef };
return { handleFiles, imageContainerRef, inputFileRef };
};
<input type="file">要素のファイル選択ダイアログを開くには、要素に対してclickイベントを起こすだけです。そのための関数(openDialog)をフックuseHooksに定めます。
// import { ChangeEventHandler, useRef, useState } from "react";
import { ChangeEventHandler, MouseEventHandler, useRef, useState } from "react";
export const useHooks = () => {
const openDialog: MouseEventHandler<HTMLButtonElement> = () => {
const inputFile = inputFileRef.current;
if (!inputFile) return;
inputFile.click();
};
// return { handleFiles, imageContainerRef, inputFileRef };
return { handleFiles, imageContainerRef, inputFileRef, openDialog };
};
FileInputコンポーネントのButtonコンポーネントにonClickハンドラとして加えるのが、フック(useHooks)から読み込んだこの関数(openDialog)です。
export const FileInput: FC = () => {
// const { handleFiles, imageContainerRef, inputFileRef } = useHooks();
const {
handleFiles,
imageContainerRef,
inputFileRef,
openDialog
} = useHooks();
return (
<div>
{/* <Button variant="contained">ファイルを選択</Button> */}
<Button variant="contained" onClick={openDialog}>
ファイルを選択
</Button>
</div>
);
};
これで、ボタンクリックによりファイル選択ダイアログが開き、選んだ画像はページに定めた矩形領域に表示されます。
選択したファイル名を表示する
つぎに、<input type="file">要素と同じ、選択したファイル名の表示です。MUIのTextFieldコンポーネントを加えましょう。位置は<input type="file">と逆に、ボタンの左側にしました。スタイル(styles.textField)も少し調整します。
import TextField from '@mui/material/TextField';
export const FileInput: FC = () => {
return (
<div>
<input
/>
<TextField variant="standard" style={{ ...styles.textField }} />
</div>
);
};
export const styles = {
textField: {
marginRight: '0.5rem'
},
};
フック(useHooks)には、選ばれたファイル名をもつ状態変数(selectedFile)が必要です。ファイルが選択されていない変数値はnullとしました。
export const useHooks = () => {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const resetSelection = () => {
fileImage.src = '';
setSelectedFile(null);
};
const handleFiles: ChangeEventHandler<HTMLInputElement> = (event) => {
if (!file.type.includes('image/')) {
}
setSelectedFile(file.name);
};
// return { handleFiles, imageContainerRef, inputFileRef, openDialog };
return {
handleFiles,
imageContainerRef,
inputFileRef,
openDialog,
selectedFile
};
};
FileInputコンポーネントは、フック(useHooks)から選択ファイルの状態変数(selectedFile)を読み込めば、ファイル名または「選択されていません」の表示が切り替えられます。
export const FileInput: FC = () => {
const {
selectedFile
} = useHooks();
return (
<div>
{/* <TextField variant="standard" style={{ ...styles.textField }} /> */}
<TextField
variant="standard"
value={selectedFile || '選択されていません'}
style={{ ...styles.textField }}
/>
</div>
);
};
要素へのドラッグ&ドロップでファイルを選択する
さらに、ファイルのドラッグ&ドロップによる選択です。<input type="file">要素は、ファイルをドロップしても選べました。今回の作例には画像表示領域がありますので、そこにファイルをドロップして選べるようにしましょう(「ドラッグ&ドロップを使用したファイルの選択」参照)。
ドラッグ&ドロップにともなうイベントはいくつかあります。そのすべてで処理を進めるわけではありません。むしろ、要らないイベントは止めなければならないのです。フック(useHooks)に加えたのは、イベントを止める関数(stopDragEvent)です。
// import { ChangeEventHandler, MouseEventHandler, useRef, useState } from "react";
import {
ChangeEventHandler,
DragEventHandler,
MouseEventHandler,
useRef,
useState
} from "react";
export const useHooks = () => {
const stopDragEvent: DragEventHandler<HTMLDivElement> = (event) => {
event.preventDefault();
event.stopPropagation();
console.log('dragevent:', event.type); // 確認用
};
return {
stopDragEvent
};
};
今回、ドラッグ&ドロップにともなって呼び出される3つのイベントonDragEnterとonDragOverおよびonDropに、フック(useHooks)から読み込んだ関数stopDragEventをハンドラとして定めました。ドラッグ&ドロップの操作を行えば、関数からイベントの種類(type)がコンソールに出力されるはずです。
export const FileInput: FC = () => {
const {
stopDragEvent
} = useHooks();
return (
<div>
{/* <div ref={imageContainerRef} style={{...styles.imageContainer}} /> */}
<div
onDragEnter={stopDragEvent}
onDragOver={stopDragEvent}
onDrop={stopDragEvent}
/>
</div>
);
};
試してみると、ファイルのドロップで選択の処理を行うべきは、onDropイベントだとわかります。他のふたつのハンドラは、このままイベントを止めてもらいましょう。
ドロップの処理に進む前に、フック(useHooks)のファイル選択の関数handleFilesには少し手を加えておきます。ボタンクリックでもファイルのドロップでも、基本的な選択の処理は共通だからです。ただし、ふたつのイベントは異なるため、ファイル選択の関数はFileListオブジェクトを受け取るように改めました。そのうえで、ボタンクリックしたときの関数(handleFileDialog)とファイルドロップしたときの関数(handleDroppedFile)を新たに加えたのです。
export const useHooks = () => {
// const handleFiles: ChangeEventHandler<HTMLInputElement> = (event) => {
const handleFiles = (files: FileList | null) => {
// const files = event.currentTarget.files;
if (!file.type.includes('image/')) {
// event.currentTarget.value = '';
if (inputFileRef.current) {
inputFileRef.current.value = '';
}
}
};
const handleFileDialog: ChangeEventHandler<HTMLInputElement> = (event) => {
const files = event.currentTarget.files;
handleFiles(files);
};
const handleDroppedFile: DragEventHandler<HTMLDivElement> = (event) => {
stopDragEvent(event);
const dataTransfer = event.dataTransfer;
const files = dataTransfer.files;
handleFiles(files);
};
return {
// handleFiles,
handleDroppedFile,
handleFileDialog,
};
};
ファイル選択のコンポーネント(FileInput)は、フックからhandleFileDialogとhandleDroppedFileを読み込んで、それぞれの要素のイベントに加えれば、ボタンクリックに加えてファイルのドロップでも選択できるようになります。
export const FileInput: FC = () => {
const {
// handleFiles,
handleDroppedFile,
handleFileDialog,
} = useHooks();
return (
<div>
<input
// onChange={handleFiles}
onChange={handleFileDialog}
/>
<div
// onDrop={stopDragEvent}
onDrop={handleDroppedFile}
/>
</div>
);
};
ファイル選択のモジュールsrc/FileInput.tsxは、これででき上がりです。全体の記述をコード001にまとめました。
コード001■ファイル選択のモジュール
import { FC } from 'react';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import { useHooks } from './hooks';
import { styles } from './styles';
export const FileInput: FC = () => {
const {
handleDroppedFile,
handleFileDialog,
imageContainerRef,
inputFileRef,
openDialog,
selectedFile,
stopDragEvent
} = useHooks();
return (
<div>
<input
type="file"
ref={inputFileRef}
accept="image/*"
onChange={handleFileDialog}
style={{ ...styles.inputFile }}
/>
<TextField
variant="standard"
value={selectedFile || '選択されていません'}
style={{ ...styles.textField }}
/>
<Button variant="contained" onClick={openDialog}>
ファイルを選択
</Button>
<div
ref={imageContainerRef}
onDragEnter={stopDragEvent}
onDragOver={stopDragEvent}
onDrop={handleDroppedFile}
style={{ ...styles.imageContainer }}
/>
</div>
);
};
ファイルのドロップで<input type="file">の選択が変更されない
一見これでよさそうです。けれど、たとえばつぎの操作をすると不具合があります。
- ページロード時のファイルが選択されていない状態から始める。
- ファイルのドロップで画像を選んで表示する。
- ボタンでファイル選択ダイアログを開く。
- ダイアログの[キャンセル]ボタンをクリックする。
標準の<input type="file">要素であれば、「選択されていません」という表示に変わり、画像は消えるはずです。ところが、ダイアログが閉じても、ファイル名と画像は表示されたままになります。これは、ファイルをドロップした時の処理の中に、<input type="file">要素が関わっていないからです。
<input type="file">要素を表示して試してみるとわかります。ファイルをドロップして選んでも、<input type="file">要素から見たら選択が変わっていないのです。ファイル選択ダイアログで[キャンセル]ボタンを押したとき、要素がそもそも「選択されていません」という認識であれば、onChangeイベントが発生しません。
export const styles = {
inputFile: {
// display: 'none'
},
};
解決する方法は、<input type="file">要素のHTMLInputElement.filesプロパティに、ドロップで取得したFileListオブジェクトを与えることです。
const resetSelection = () => {
const handleDroppedFile: DragEventHandler<HTMLDivElement> = (event) => {
if (inputFileRef.current) {
inputFileRef.current.files = files;
}
handleFiles(files);
};
};
つい、HTMLInputElement.valueプロパティを書き替えたくなるかもしれません。けれど、この値に空文字('')以外の値は与えることができないのです(「注」参照)。
これで、カスタムフックのモジュールsrc/hooks.tsも書き上がりました。記述全体は、つぎのコード002のとおりです。
コード002■カスタムフックのモジュール
import {
ChangeEventHandler,
DragEventHandler,
MouseEventHandler,
useRef,
useState
} from 'react';
import { imageDisplaySize } from './styles';
const fileImage = new Image();
export const useHooks = () => {
const imageContainerRef = useRef<HTMLDivElement>(null);
const inputFileRef = useRef<HTMLInputElement>(null);
const [objectURL, setObjectURL] = useState('');
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const manipulateImageSize = (url: string) => {
fileImage.src = url;
fileImage.onload = () => {
const width = fileImage.naturalWidth;
const height = fileImage.naturalHeight;
const ratioWidth = width / imageDisplaySize.width;
const ratioHeight = height / imageDisplaySize.height;
if (ratioWidth > ratioHeight) {
fileImage.width = imageDisplaySize.width;
fileImage.height = height / ratioWidth;
} else {
fileImage.width = width / ratioHeight;
fileImage.height = imageDisplaySize.height;
}
};
};
const resetSelection = () => {
fileImage.src = '';
setSelectedFile(null);
const imageContainer = imageContainerRef.current;
if (imageContainer && fileImage.parentNode === imageContainer) {
imageContainer.removeChild(fileImage);
}
if (objectURL) {
window.URL.revokeObjectURL(objectURL);
setObjectURL('');
}
};
const handleFiles = (files: FileList | null) => {
resetSelection();
if (!files || files?.length === 0) return;
const file = files[0];
if (!file.type.includes('image/')) {
if (inputFileRef.current) {
inputFileRef.current.value = '';
}
return;
}
setSelectedFile(file.name);
const imageContainer = imageContainerRef.current;
if (!imageContainer) return;
const objectURL = window.URL.createObjectURL(file);
manipulateImageSize(objectURL);
imageContainer.appendChild(fileImage);
setObjectURL(objectURL);
};
const openDialog: MouseEventHandler<HTMLButtonElement> = () => {
const inputFile = inputFileRef.current;
if (!inputFile) return;
inputFile.click();
};
const stopDragEvent: DragEventHandler<HTMLDivElement> = (event) => {
event.preventDefault();
event.stopPropagation();
};
const handleFileDialog: ChangeEventHandler<HTMLInputElement> = (event) => {
const files = event.currentTarget.files;
handleFiles(files);
};
const handleDroppedFile: DragEventHandler<HTMLDivElement> = (event) => {
stopDragEvent(event);
const dataTransfer = event.dataTransfer;
const files = dataTransfer.files;
if (inputFileRef.current) {
inputFileRef.current.files = files;
}
handleFiles(files);
};
return {
handleDroppedFile,
handleFileDialog,
imageContainerRef,
inputFileRef,
openDialog,
selectedFile,
stopDragEvent
};
};