Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

dotfilesをサクッとプレビューするためにDockerイメージを作ろう

「他人のdotfilesをちょっと試してみたいけど、環境を汚したくないからやらない」、こういうこと、めっちゃありますよね。僕はあるので進めますね。コレを解消できないかと考えてたんですが、「Dockerイメージをビルドすれば良いじゃん」って気づいたのでやり方の紹介です。

dotfilesをプレビューする

実際にDockerイメージを作ったので、以下のコマンドで僕のdotfilesをプレビューできます↓

docker run -it --rm ghcr.io/pandanoir/dotfiles-preview

DockerイメージをGitHub Actionsでビルドする

GitHubにはコンテナレジストリがあります (ghcr.io)。GH Actionsでここにビルド&プッシュすればGitHubで全部完結させられます。フローはこんな感じ↓

on:
  push:
    branches: [ master ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3 # マルチプラットフォーム向けビルドのためにbuildxをセットアップ
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          push: true
          file: ./preview.dockerfile
          platforms: linux/amd64,linux/arm64
          tags: ghcr.io/${{ github.repository_owner }}/dotfiles-preview:latest

あとはpreview.dockerfileを書けばOK

# neovimでlanguage serverのインストール時にnpmを使いたいのでnodeイメージを使用
FROM node:24-slim

# 自分の環境で使ってる各種ツールをインストール
RUN apt update && \
    apt install -y zsh git curl fzf unzip gcc && \
    rm -rf /var/lib/apt/lists/*
RUN curl -sS https://starship.rs/install.sh | sh -s -- --yes
RUN curl -fsSL https://deno.land/x/install/install.sh | sh

# 最新ビルドのneovimをインストール
RUN if [ "$(uname -m)" = "x86_64" ]; then ARCH="linux-x86_64"; else ARCH="linux-arm64"; fi && \
    curl -LO https://github.com/neovim/neovim/releases/download/stable/nvim-${ARCH}.tar.gz && \
    tar xzf nvim-${ARCH}.tar.gz && \
    rm nvim-${ARCH}.tar.gz && \
    mkdir -p /root/local && mv nvim-${ARCH} /root/local/nvim

COPY . /root/dotfiles
WORKDIR /root/dotfiles

RUN ./install.sh

CMD ["/bin/zsh"]

おわり

みんなもDockerイメージを作って配布してくれ。いっぱい試したいので。

ComponentPropsWithoutRef<'button'> と ButtonHTMLAttributes<HTMLButtonElement> は何が違うのか?

A. 型定義的には同じ *1。HTML要素向けのComponentPropsの実装でXxxHTMLAttributesが使われる、という関係。

結論としては同じものとみなして良いんですが、じゃあどう使い分ければ良いのか? という話ですよね。この話をするにはそもそもComponentPropsが何者なのかを知る必要があります。

ComponentPropsは コンポーネントのプロパティを取得するユーティリティ型

ComponentPropsは、渡されたコンポーネントまたはHTML要素のプロパティを取得するユーティリティ型 です。こんな感じになります↓

const MyComponent = (props: MyProps) => <div />;

type MyComponentProps = ComponentProps<typeof MyComponent>; // MyComponentのプロパティ(MyProps)が得られる
type ButtonElementProps = ComponentProps<'button'>; // button要素に設定できる属性が取得できる

ButtonHTMLAttributesとの違い

ButtonHTMLAttributes も button 要素の属性を表現しています。というか、ComponentProps<'button'> の内部実装はほぼ ButtonHTMLAttributes そのものです。

これは ComponentProps の実装を追いかけるとわかります。まず、 ComponentProps<'button'>JSX.IntrinsicElements['button'] です。

    type ComponentProps<T extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>> = T extends
        JSXElementConstructor<infer Props> ? Props
        : T extends keyof JSX.IntrinsicElements ? JSX.IntrinsicElements[T]
        : {};

該当コード

JSX.IntrinsicElements['button']DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> です。

        interface IntrinsicElements {
            // ...
            button: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>;

該当コード

DetailedHTMLPropsはrefを追加するためのものなので、ほぼ ButtonHTMLAttributes<HTMLButtonElement>ComponentProps<'button'> の実体です。

結論: ComponentProps を使うのが良さげ

このように、実装としてはどちらを使っても同じ効果が得られます。ただし、結論としては、「コンポーネント(HTML要素)のプロパティ(属性)を扱いたいシーンであればComponentPropsを使う」のが良いと思います。ButtonHTMLAttributesを直接使う代わりにComponentPropsを使えば、「button要素のプロパティを扱いたいんだな」とコードの意図が明確に表現できるので。

*1:正確に言えば、ButtonHTMLAttributes にrefが追加されてからrefをOmitしたものがComponentPropsWithoutRef。なので完全に一致しているわけではないが、ユースケース上は全く同じとみなして良いはず

dom-testing-libraryはブラウザ環境でも動く

「getByRoleをブラウザ環境でも使えると嬉しいな〜」と思ってたんですが、普通にdom-testing-libraryはブラウザ環境でも動かせる(実DOM APIと互換がある)みたいです。

import { useEffect } from 'react';
import { screen } from '@testing-library/dom';

export function App() {
  useEffect(() => {
    console.log(screen.getByRole('button', { name: '送信' }));
  });
  return <button type="button">送信</button>;
}

デバッグやE2Eテスト、ログ送信なんかで使えるシーンがあるかもしれません。

listboxをアクセシブルに実装したい

listbox というUIパターンを実装しました。ちょっと凝ったラジオボタンリストみたいなものです↓

仕様

クリック時の挙動はだいたいラジオボタンと同じです。

キーボード操作時がラジオボタンと違っています。ラジオボタンではフォーカス移動と同時に選択が行われますが、このリストボックスではフォーカスを移動させただけでは選択されず、スペースで決定することで初めて選択されます。

実装

実装はこんな感じです。azukiazusaさんの実装を大いに参考にしてます→ 【React】アクセシビリティに考慮したリストボックスを実装する (コンボボックスがない、ステートではなくブラウザのフォーカス機能でフォーカス管理をしているあたりが異なります)

const TinyListbox = ({
  options,
  selectedIndex,
  onChangeIndex,
}: {
  options: string[];
  selectedIndex: number;
  onChangeIndex: (index: number) => void;
}) => {
  const optionsRef = useRef<(HTMLDivElement | null)[]>([]);

  return (
    <div
      role="listbox"
      tabIndex={-1}
      onKeyDown={(e) => {
        const focusedIndex = optionsRef.current.findIndex(
          (ref) => document.activeElement === ref
        );
        if (focusedIndex === -1) return;

        // 上下キーでフォーカスを移動させる
        switch (e.key) {
          case "ArrowDown":
            e.preventDefault();
            optionsRef.current[(focusedIndex + 1) % options.length]?.focus();
            break;
          case "ArrowUp":
            e.preventDefault();
            optionsRef.current[
              (focusedIndex - 1 + options.length) % options.length
            ]?.focus();
            break;
          default:
            break;
        }
      }}
      style={{ display: "flex", gap: "8px", outline: "none" }}
    >
      {options.map((option, index) => (
        <div
          key={option}
          role="option"
          aria-selected={selectedIndex === index}
          tabIndex={selectedIndex === index ? 0 : -1}
          onKeyDown={(e) => {
            // フォーカスされてるときにスペースあるいはエンターされたら選択する
            switch (e.key) {
              case "Enter":
              case " ":
                e.preventDefault();
                onChangeIndex(index);
                break;
            }
          }}
          onClick={() => onChangeIndex(index)}
          style={{
            padding: "4px 8px",
            background: selectedIndex === index ? "#ddd" : "#fff",
            cursor: "pointer",
          }}
          ref={(el) => {
            optionsRef.current[index] = el;
            return () => (optionsRef.current[index] = null);
          }}
        >
          {option}
        </div>
      ))}
    </div>
  );
};

参考

ターミナルでspinnerを表示する

デモ

これを実現するためのshowSpinner関数を作りました。

function showSpinner() {
  const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
  let spinnerIndex = 0;
  const spinner = setInterval(() => {
    process.stdout.write(`\r\x1b[2K${spinnerFrames[spinnerIndex++ % spinnerFrames.length]}`);
  }, 80);
  return () => {
    clearInterval(spinner);
    process.stdout.write("\r\x1b[2K");
  };
}

コードはこんな感じです↓

  1. スピナーを表示する
  2. 80ms経つ
  3. キャリッジリターン(\r)でカーソル位置を先頭に戻す
  4. \x1b[2K で現在行の内容をクリア
  5. 次のスピナーを表示する (カーソル位置が先頭に戻ってるので前回のスピナーが上書きされる)
  6. cleanupされるまで動作を繰り返す。

使い方はこんな感じ↓

const cleanupSpinner = showSpinner();
await asyncFn();
cleanupSpinner();