かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

TypeScript React クリップボードにコピーする hooks とコンポーネント作ってみた

input タグに入力された値をクリップボードにコピーする hooks と コンポーネントを作ってみたのでログとしてメモ

📝 Clipboard.writeText() を使ってクリップボードにテキストをコピーすることができる

clipboardjs のようなライブラリもあったのですが、ブラウザで動作するフロントエンドなら Clipboard API を使えばクリップボードに書き込みが可能そうだったのでライブラリなしに実装しました

Clipboard: writeText() method
The writeText() method of the Clipboard interface writes the specified text to the system clipboard, returning a Promise that is resolved once the system clipboard has been updated.
cf. Clipboard: writeText() method - Web APIs | MDN

const onCopy = async (text: string) => {
  try {
    await navigator.clipboard.writeText(text);
  } catch (error) {
    console.error(error.message);
  }
}

Clipboard にテキストをコピーする hooks

// /hooks/useCopyClipboard.ts
import { useCallback, useEffect, useState } from "react";

export const useCopyClipboard = () => {
  const [hasCopied, setHasCpied] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const onCopy = useCallback(async (text: string) => {
    try {
      await navigator.clipboard.writeText(text);
      setHasCpied(true);
    } catch (err) {
      if (err instanceof Error) {
        console.error(err.message);
        setError(err);
        return;
      }

      console.error(err);
      setError(new Error("Failed to copy"));
    }
  }, []);

  useEffect(() => {
    let timeoutId: number | null = null;
    if (hasCopied) {
      timeoutId = window.setTimeout(() => {
        setHasCpied(false);
      }, 1500);
    }

    return () => {
      if (timeoutId) {
        window.clearTimeout(timeoutId);
      }
    };
  }, [hasCopied]);

  return {
    onCopy,
    hasCopied,
    isError: !!error,
    error,
  };
};

useCopyClipboard hooks から返される onCopy に渡した文字列をクリップボードにコピーするシンプルな hooks です (※ Chakra UI の useClipboard を参考にしました)

input に入力された文字列をクリップボードにコピーする

import { useRef } from "react";
import { useCopyClipboard } from "./hooks/useCopyClipboard";

export default function App() {
  const inputRef = useRef<HTMLInputElement>(null);
  const { onCopy, hasCopied, isError } = useCopyClipboard();

  const handleCopyClipboard = () => {
    if (!inputRef.current) {
      return;
    }
    onCopy(inputRef.current.value);
  };

  return (
    <div className="App">
      <input ref={inputRef} type="text" defaultValue=""  />
      <button type="button" onClick={handleCopyClipboard}>
        {hasCopied ? "Copied!" : "Copy!"}
      </button>
      {isError && <small className="text-error">Failed to copy<small>}
    </div>
  );
}

Sample

iframe だとクリップボードにコピーできないので codesandbox を直接開いてください

ライブラリを使うこと無くクリップボードに任意のテキストをコピーさせることができてびっくりしました!
と、同時に Clipboard API にはブラウザのサポートが完全なわけではないですがクリップボードから読み込みもあるので、evil なサイトではクリップボードの入力値を不正に API にも送るとか出来てしまうのかな〜なども気になるところでした。

Clipboard API - Browser compatibility | MDN Clipboard API - Web APIs | MDN

evil な使い方が出来てしまうんので navigator.clipboard.readText はサポートがマチマチなのかも?

おわり ₍ᐢ. ̫.ᐢ₎


[参考]

ES2023 JavaScript 配列から特定の要素を削除するのに toSpliced が便利

フロントエンドでやることはだいたい配列の操作だと感じてます。
その中でも元の配列を非破壊に特定のインデックスの要素を配列から削除するのが意外と面倒でした

今まで方法

1. filter で削除する要素を取り除く

const result = array.filter((item) => item.id !== deleteID);

この方法は非破壊に要素を削除した配列を削除できるが、配列を全て捜査する分処理が無駄になる

2. 削除する index を探して slice で取り除く

const deleteIndex = array.findIndex((item) => item.id === deleteID);
const result = [
  ...array.slice(0, deleteIndex),
  ...array.slice(deleteIndex + 1)
];

削除する要素の index を見つけるまで配列を捜査して、slice で配列から削除する要素を除いた配列を作成する方法
slice で index の要素を削除した配列を作成するコードが若干見通しが悪い

3. 配列を先にコピーして splice で要素を削除する

const deleteIndex = array.findIndex((item) => item.id === deleteID);
const copyArray = […array];
// or const Array = structuredClone(array); (deep copy)
copyArray.splice(deleteIndex, 1);
const result = copyArray;

.splice(deleteIndex, 1) で特定の index の要素を削除できるが、splice() は元の配列を変更してしまう破壊メソッドなので、先に配列を スプレッド構文 (Shallow copy)structuredClone (Deep copy) コピーしておく必要がある

toSpliced() メソッド

toSpliced() は Array インスタンスのメソッドで、 splice() メソッドに対応するコピーメソッドです。これは、指定された位置の要素を除去したり置き換えたりした新しい配列を返します。
cf. Array.prototype.toSpliced() - JavaScript | MDN

toSpliced() メソッドは splice() メソッドでできる配列の要素の削除・追加・置き換えを非破壊的にでき、新しい配列を返してくれる

const deleteIndex = array.findIndex((item) => item.id === deleteID);
const result = array.toSpliced(deleteIndex, 1);

従来の方法の 2, 3 を合わせてシンプルにしたように書ける!

Tips toSpliced()splice() の挙動の違い

splice() を使うことはあまりないと思いますが、破壊/非破壊だけでなく返り値に違いがあるので注意が必要です

  • toSpliced() は元の配列を変化させない (非破壊)・新しい配列を返す
  • splice() は元の配列を変化させる (破壊)・削除した要素の配列を返す
Sample

要素の削除

// toSpliced
const a1 = [1, 2, 3, 4, 5];
const res1 = a1.toSpliced(1, 2);

console.log(a1);   // [1, 2, 3, 4, 5]
console.log(res1); // [1, 4, 5]

// splice
const a2 = [1, 2, 3, 4, 5];
const res2 = a2.splice(1, 2);

console.log(a2);   // [1, 4, 5]
console.log(res2); // [2, 3]

要素を削除して追加

// toSpliced
const a1 = [1, 2, 3, 4, 5];
const res1 = a1.toSpliced(1, 2, 'a', 'b', 'c');

console.log(a1);   // [1, 2, 3, 4, 5]
console.log(res1); // [1, 'a', 'b', 'c', 4, 5]

// splice
const a2 = [1, 2, 3, 4, 5];
const res2 = a1.splice(1, 2, 'a', 'b', 'c');

console.log(a2);   // [1, 'a', 'b', 'c', 4, 5]
console.log(res2); // [2, 3]

これからは配列からの要素の削除は toSpliced() を使って見通しよく書いていけそうで嬉しいです!
配列からの削除めんどいなと思って調べなかったら気づくこと無く slicefilter で書いているところでした… ES2022, ES2023 で配列の非破壊メソッドが結構追加されているようなので、サボらずちゃんとキャッチアップしようと思ったのでした

おわり₍ᐢ. ̫.ᐢ₎


[参考]

ダンジョン飯の 九井 諒子 さん「竜の学校は山の上」の人だったのか!

Next.js 構造化されたパンくずリストを作りたい

昨年メディアのような SEO が大切になる Next.js の案件があり、ページの構造を表すパンくずリスト (BreadcrumbList) も重要なのでちゃんと構造化されたものを作ってみたのでそのメモ。

構成

  • Next.js v13 (Pages Router)
  • React v18
  • TypeScript v5.2

パンくずリスト( BreadcrumbList) の構造化データ

Google が公開しているページを参考にした 古の昔はパンくずリストの HTML にプロパティを付ける RDFa や microdata のような形式が主流だった記憶だが、案件を実装当時 (2023年末) では構造を <script type="application/ld+json"> として別途マークアップする JSON-LD が推奨されているらしい

構造化データに関する一般的なガイドライン
リッチリザルトを表示できるようにするには、サポートされている 3 つの形式のいずれかを使用して、サイトのページをマークアップする必要があります。
JSON-LD(推奨)
cf. Google 検索上の構造化データガイドライン | Google 検索セントラル  |  ドキュメント  |  Google for Developers

パンくずリストJSON-LD

{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [{
    "@type": "ListItem",
    "position": 1,
    "name": "Books",
    "item": "https://example.com/books"
  },{
    "@type": "ListItem",
    "position": 2,
    "name": "Science Fiction",
    "item": "https://example.com/books/sciencefiction"
  },{
    "@type": "ListItem",
    "position": 3,
    "name": "Award Winners"
  }]
}

cf. パンくずリスト(BreadcrumbList)のマークアップを追加する方法 | Google 検索セントラル  |  ドキュメント  |  Google for Developers

"@context": "https://schema.org", "@type": "BreadcrumbList" の部分は固定なので itemListElement 内のリストを動的に作成すればOK、更に現在のページの URL (item) は不要なのでサイトトップと現在のページの親のデータを持たせておけば良さそうです

JSON-LD なパンくずリストの実装

パンくずリストJSON-LD の型を作成

// types/breadcrumb.d.ts

export type BreadcrumbItem = {
  '@type': 'ListItem';
  position: number;
  name: string; // title
  item?: string; // URL
};

export interface BreadcrumbJsonLd {
  '@context': 'https://schema.org',
  '@type': 'BreadcrumbList',
  itemListElement: BreadcrumbItem[],
}

パンくずリストの HTML と JSON-LD を出力するコンポーネント

// /components/BreadcrumbList.tsx
import { FC } from 'react';
import Link from 'next/link';
import Script from 'next/script';
import { BreadcrumbJsonLd, BreadcrumbItem } from '@/types/breadcrumb';

type BreadcrumbListProps = {
  breadcrumbData: BreadcrumbItem[];
};

export const BreadcrumbList: FC<BreadcrumbListProps> = ({ breadcrumbData }) => {
  return (
    <>
      <JsonLd breadcrumbData={breadcrumbData} />
      <ol className='breadcrumb'>
        {breadcrumbData.map((item) => (
          <li key={item.position} className="breadcrumbItem">
            <BreadcrumbItem {…item} />
          </li>
        )}
      </ol>
    </>
  );
};

const BreadcrumbItem: FC<BreadcrumbItem> = ({ name, item }) => {
  const label = name === SITE_NAME ? 'Home' : name;
  if (item) {
    return <Link href={item} className='breadcrumbLink'>{title}</Link>;
  }
  // current
  return <span className="breadcrumbLink current">{label}<span>;
}

// JSON-LD を出力するコンポーネント
type JsonLdProps = {
  breadcrumbData: BreadcrumbItem[];
};

const JsonLd: FC<JsonLdProps> = ({ breadcrumbData }) => {
  const jsonLdSchema: BreadcrumbJsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BreadcrumbList',
    itemListElement: breadcrumbData,
  };

  return (
    <Script
      id='BreadcrumbList-JSON-LD'
      type='application/ld+json'
      strategy='beforeInteractive'
    >
      {JSON.stringify(jsonLDSchema)}
    </Script>
  );
};

パンくずリスのデータを各ページから渡して出力すればOK

import { BreadcrumbItem } from '@/types/breadcrumb';
import { BreadcrumbList } from '@/components/BreadcrumbList';

const breadcrumbData: BreadcrumbItem[] = [
  {
    '@type': 'ListItem',
    position: 1,
    name: SITE_NAME,
    item: SITE_HOME_URL,
  },
  {
    '@type': 'ListItem',
    position: 2,
    name: PARENT_PAGE_NAME,
    item: PARENT_PAGE_URL,
  },
  {
    '@type': 'ListItem',
    position: 3,
    name: THIS_PAGE_TITLE
  }
] ;

export default function Page() {
  return (
    <PageLayout>
      <BreadcrumbList breadcrumbData={breadcrumbData} />
      <PageContent>
    </PageLayout>
  );
}

動的にページが決まる場合は breadcrumbData を動的に作成してしまえば OK。
こんな感じで構造化された JSON-DL のパンくずリストを作成することができました!

今回は基本的に SSG をする仕様だったので JSON-DL を出力する Scriptstrategy='beforeInteractive' を指定してページが表示される前に JSON-LD のスクリプトがロードされるようにしたが、JSON-LD と next/script の strategy の仕様に明るくないので strategy の最適な指定方法が理解できているわけではないです。

おわり


[参考]

app router も Remix もまだ全然さわれてないのでそろそろ危機感覚えてきてる…