複数行のテキストでも下線追従アニメーション
小ネタです。
ホバー時に下線が左から右に流れるような追従アニメーション、今までafter疑似要素のscaleをtransitionする形で実装していたのですが、
この方法だと複数行対応ができないという欠点がありました。
- テキストをinlineブロックで囲う
background-image
で下線の色を指定し、background-size
をtransitionさせる
これだけで、複数行にも対応させることができました。
inline要素は改行されたとしてもbackground-size-widthが変わらないというのは意外と盲点でした。
.inline { background-image: linear-gradient(to bottom, transparent, #4aed2b); background-repeat: no-repeat; background-position: left bottom; background-size: 0% 10px; transition: background .3s ease-out; cursor: pointer; } .outside:hover .inline { background-size: 100% 10px; }
Demo
See the Pen Untitled by Yumiko Kokubu (@yumikokh) on CodePen.
React Testing Libraryの勉強メモ
React Testing Libraryについて勉強したので備忘録です。 ソースコードはこちら
テストの種類
Unit Test, Integration Testの手法 → Jest + React Testing Library(EnzymeよりHooksとの相性がいい)
Jest
- npm test
- test case
- expect
React Testing Library
- render
- userEvent
- custom hook
CLI
Test Suites: テストが書かれたファイルの数
Tests: 書かれたテストの数
テストの名前を表示させるには
"test": "react-scripts test --env=jsdom --verbose"
UserEvent Test
it("Should trigger output function", async () => { const outputConsole = jest.fn(); render(<RenderInput outputConsole={outputConsole} />); const inputValue = screen.getByPlaceholderText("Enter"); await userEvent.type(inputValue, "test"); await userEvent.click(screen.getByRole("button")); expect(outputConsole).toHaveBeenCalledTimes(1); });
jest.fn()
でダミーのprops関数を定義し、その関数が何回呼ばれたかを評価するuseEvent
はPromiseを返す(React18〜)getByRole
で使うroleは明示的に定義していなければhtmlタグとの相関はこのようになっている
API Mock Test
import { rest } from "msw"; import { setupServer } from "msw/node"; import MockServer from "./MockServer"; // 代用されるAPIの定義 const server = setupServer( rest.get("https://jsonplaceholder.typicode.com/users/1", (req, res, ctx) => { return res(ctx.status(200), ctx.json({ username: "Bred dummy " })); }) ); // テストファイルの最初に1回実行される beforeAll(() => server.listen()); // テストケースが終わる度に実行される afterEach(() => { server.resetHandlers(); }); // テストファイルの最後に1回実行される afterAll(() => { server.close(); }); describe("Mocking API", () => { it("[Fetch Success] データが正しくフェッチされてボタンがdisabledになる", async () => { render(<MockServer />); await userEvent.click(screen.getByRole("button")); expect(await screen.findByRole("heading")).toHaveTextContent("Bred dummy"); expect(screen.getByRole("button")).toHaveAttribute("disabled"); }); it("[Fetch failure] エラーメッセージが表示されボタンが有効化される", async () => { // レスポンスを書き換える、このitの中でのみ有効 server.use( rest.get( "https://jsonplaceholder.typicode.com/users/1", (req, res, ctx) => { return res(ctx.status(404)); } ) ); render(<MockServer />); await userEvent.click(screen.getByRole("button")); expect(await screen.findByTestId("error")).toHaveTextContent( "Fetching Failed!" ); expect(screen.queryByRole("heading")).toBeNull(); expect(screen.getByRole("button")).not.toHaveAttribute("disabled"); }); });
- getBy, queryBy, findBy
- 通常はgetBy
- getByは対象がないとエラーになるため、対象が 存在しない ことを評価するためには queryBy
- 非同期で対象が見つかるまで待機する場合はPromiseを返す findBy
Reducer Test
describe("increment action", () => { let initialState = { mode: 0, value: 1, }; it("Should increment by 1 with mode 0", () => { const action = { type: increment.type }; const state = reducer(initialState, action); expect(state.value).toEqual(2); }); it("Should increment by 100 with mode 1", () => { initialState = { mode: 1, value: 1, }; const action = { type: increment.type }; const state = reducer(initialState, action); expect(state.value).toEqual(101); }); });
Extra Reducer Test
const initialState = { mode: 0, value: 0, }; it("Should output 100 + payload when fulfilled", () => { const action = { type: fetchDummy.fulfilled.type, payload: 5 }; const state = reducer(initialState, action); expect(state.value).toEqual(105); });
Integration Test
let store; beforeEach(() => { store = configureStore({ reducer: { customCounter: customCounterSlice, }, }); }); it("Should display value with incrementByAmount", async () => { render( <Provider store={store}> <Redux /> </Provider> ); await userEvent.type(screen.getByPlaceholderText("Enter"), "30"); await userEvent.click(screen.getByText("Increment By Amount")); expect(screen.getByTestId("count-value")).toHaveTextContent(30); });
CustomHook Test
it("Should increment by 1", () => { const { result } = renderHook(() => useCounter(3)); expect(result.current.count).toBe(3); act(() => { result.current.increment(); }); expect(result.current.count).toBe(4); });
- renderHook はコンポーネント外でhookを実行できるようにするための関数
- 内部でstateに変更を加える等のふるまいがある場合は act内で実行する
ふむふむ
Next.jsでmp3をimportする
前回はfile-loaderで動画メディアを変換する方法について書きましたが、
今回はmp3ファイルをimportします。
next.config.js
のwebpack設定に以下を加えます。
const nextConfig = { ... webpack(config, options) { config.module.rules.push({ test: /\.mp3$/, use: { loader: "url-loader", options: { limit: 8192, }, }, }); return config; }, };
url-loader は対象のファイルをbase64形式のDataURLへ変換し、js内にバンドルします。
limit
オプションに数字か文字列を指定すると、その値を最大バイトとしてそれ以上のサイズのファイルは、フォールバックとしてfile-loaderで変換されます。 (file-loaderはurl-loaderの初期値のフォールバック先です)
file-loader は url-loader を異なり、ファイルをコピーし、import先ではそのURLを返します。
大きなファイルはDataURLへ変換するとバンドルサイズが肥大化するため、limit
オプションを加えておきましょう。
ビルドに含めないローカルファイルをレンダラーに取り込む
Electron内で使用する頻繁に更新されるデータ(例えばjsonファイル)を
アプリのビルドをせずに、対象のファイルの差し替えで済むようにしました。
フローとしては、レンダラープロセスが読み込む準備ができたら、メインプロセスへリクエストを送り、メインプロセスでローカルファイルのデータを返します。
レンダラープロセスとメインプロセスの双方向通信には ipcMain.handle
と ipcRenderer.invoke
を使います。
例えば、Mac用にビルドされるアプリと同じ階層にdata.jsonを設置し、そのデータをアプリ内に取り込む場合はこのようになります。
メインプロセスでは ipcMain.handle
でイベントのコールバックを設定します。
app.on("ready", async () => { ... ipcMain.handle("load:data", (_event) => { const data = fs.readFileSync(join(app.getAppPath(), "../../../../data.json"), { encoding: "utf-8", }); return data; }); })
app.getAppPath()
は現在のアプリケーションのディレクトリを返すメソッドです。
MacとWindowsで階層が異なるので、Win用が必要であれば出しわけが必要です。
レンダラープロセスは ipcRenderer.invoke
でイベントを呼び出し、先程メインプロセスで指定したコールバックの返り値を受け取ります。
useEffect(() => { async function getData() { try { const data = await global.ipcRenderer.invoke("load:data"); console.log(JSON.parse(data)); } catch (e) { console.error(e); } } getData(); }, []);
global.ipcRenderer
は preload.ts
で定義しています。
参考
Next.jsでvideoをimportする
publicディレクトリが使えないという制約があり、
Next.jsのコンポーネント内でmp4といった動画メディアをimportする必要がありました。
初期値は動画メディア用のloaderが設定されていないため、
import MOVIE from "../assets/movie.mp4"
のようにimportすることができません。
解決策
next-videos - npm を利用します。 内部的にはfile-loaderで変換されているようです。
next.config.js
を以下のように書き換えます。
// next.config.js const withVideos = require("next-videos"); const nextConfig = { ... }; module.exports = withVideos(nextConfig);
型定義で怒られたので定義します。
declare module "*.mp4" { const src: string; export default src; }
これでvideo要素をimportし表示させることできるようになりました。
import Movie from "../assets/title.mp4"; export function Movie() { return <video src={Movie} /> }