🏋

propsからaria-*のみを抽出するutilityを作った

に公開

始めに

アクセシビリティの対応でaria-*属性を設定することがあると思いますが、自作コンポーネントにこれらの属性も提供するとpropsの数が増えて少し読みづらくなってしまうと思います。スプレッド演算で一括でDOM要素に流し込めるのであれば気にしなくても良いと思いますが、multipleのような渡されるpropによって型が変わる場合は分割させる訳にいかないため、スプレッド演算で一括代入するのは難しいケースがあると思います。

import type { FC, AriaAttributes } from 'react';

type Props = {
  size?: 'small' | 'medium' | 'large'
} & Pick<AriaAttributes, 'aria-label' | 'aria-labelledby'> & (
  // multipleがついているときは複数、ついていないときは単数になるように型設定
  | {
    multiple: true
    value: number[]
    onChangeValue: (newValue: number[]) => void
  }
  | {
    multiple?: false
    value: number | null
    onChangeValue: (newValue: number | null) => void
  }
)

const Input: FC<Props> = ({
  size,
  // やむを得ず個別に抽出しようとするとケバブケースは変数にできないためキャメルケースに変換する必要がある
  'aria-label': ariaLabel,
  'aria-labelledBy': ariaLabelledBy,
  // multipleの結果によってvalue, onChangeValueの型が変わるため分割代入できない
  // (無理矢理分割代入するとvalue: number[] | number | nullみたいになって型が確定できなくなる)
  ...restProps,
}) => {

}

aria-*部分だけpickできたら便利だなぁと思いそのutilityを作ってみたので、それについて記事にまとめました。

propsからaria-*とそれ以外に分けるutilityの作成

まず型でpropsからaria-*のキーのみを抽出したいと思います。Extract<keyof P,'aria-${string}'>aria-*のキーだけ抽出できるため、これをMapped Typesに当て込めるとaria-*のみのオブジェクトが取得できます。

aria-*のみのオブジェクト型を取得する
/** aria-*を抽出する */
type PickAriaProps<P extends object> = {
  [K in Extract<keyof P, `aria-${string}`>]: P[K];
};

以下の記事で説明してますが、unionなオブジェクトの場合はそれぞれに対してPickさせる必要があるので以下のようにDistributive Conditional Typeを使って分配させる必要があります。

https://zenn.dev/numa_san/articles/fb3bb4a870bec5

unionなオブジェクトも考慮したaria-*のみのオブジェクト型を取得する
/** unionなオブジェクトに対して、それぞれPickする */
type DistributivePickAriaProps<P extends object> = P extends any
  ? PickAriaProps<P>
  : never;

今回は念の為aria-*以外のオブジェクトも返そうと思っていますが、これは逆をすれば良いので以下のようなコードになります。

aria-*を取り除いたunionなオブジェクトを取得する
/** aria-*を取り除く */
type OmitAriaProps<P extends object> = {
  [K in Exclude<keyof P, `aria-${string}`>]: P[K];
};
/** unionなオブジェクトに対して、それぞれOmitする */
type DistributiveOmitAriaProps<P extends object> = P extends any
  ? OmitAriaProps<P>
  : never;

後はこの型を使って中身を実装します。

import { partition, pick } from 'lodash-es';

/**
 * propsからaria-*属性とそれ以外に分配する
 * @param props - props
 */
export const separateAriaProps = <P extends object>(
  props: P
): {
  /** aria属性だけ抽出したprops */
  ariaProps: DistributivePickAriaProps<P>;
  /** aria属性を取り除いたprops */
  otherProps: DistributiveOmitAriaProps<P>;
} => {
  const keys = Object.keys(props);
  const [ariaKeys, otherKeys] = partition(keys, (key) =>
    key.startsWith('aria-')
  );

  return {
    ariaProps: pick(props, ariaKeys) as DistributivePickAriaProps<P>,
    otherProps: pick(props, otherKeys) as DistributiveOmitAriaProps<P>,
  };
};

使用例

例えば以下のようなトグルボタンUIをコンポーネント化するときにmultipleの条件分岐やボタンをテキストorアイコンで表示するといった複雑な型設定でも良い感じにaria-*部分だけ抽出でき、otherPropsもconditionalな情報を維持できています。

トグルボタンUIのコンポーネント化
import type { FC, ReactNode, AriaAttributes } from 'react';
import { ToggleButtonGroup, ToggleButton } from '@mui/material';

import { separateAriaProps } from '../utils/separateAriaProps';

export type InputToggleButtonOption = { value: number } & (
  | {
      /** ラベル */
      label: string;
    }
  | ({
      /** アイコン */
      icon: ReactNode;
    } & Pick<AriaAttributes, 'aria-label'>)
);

export type InputToggleButtonGroupProps = {
  /** ボタンリスト */
  buttons: InputToggleButtonOption[];
} & Pick<AriaAttributes, 'aria-label' | 'aria-labelledby'> &
  (
    | {
        /** 複数選択可能か */
        multiple: true;
        /** 値 */
        value: number[];
        /**
         * 値更新時
         * @param newValue - 新しい値
         */
        onChangeValue: (newValue: number[]) => void;
      }
    | {
        /** 複数選択可能か */
        multiple?: false;
        /** 値 */
        value: number | null;
        /**
         * 値更新時
         * @param newValue - 新しい値
         */
        onChangeValue: (newValue: number | null) => void;
      }
  );

export const InputToggleButtonGroup: FC<InputToggleButtonGroupProps> = ({
  buttons,
  ...restProps
}) => {
  const { ariaProps, otherProps: conditionalProps } =
    separateAriaProps(restProps);

  return (
    <ToggleButtonGroup
      color="primary"
      value={conditionalProps.value}
      exclusive={!conditionalProps.multiple}
      {...ariaProps}
      onChange={(_, newValue) => {
        if (conditionalProps.multiple) {
          conditionalProps.onChangeValue(newValue as number[]);
        } else {
          conditionalProps.onChangeValue(newValue as number | null);
        }
      }}
    >
      {buttons.map((button) => {
        const { ariaProps } = separateAriaProps(button);
        const { value } = button;
        const content = 'icon' in button ? button.icon : button.label;
        return (
          <ToggleButton key={value} value={value} {...ariaProps}>
            {content}
          </ToggleButton>
        );
      })}
    </ToggleButtonGroup>
  );
};

以下に検証コードを載せますので、型推論の様子を見たい方はこちらをご参照ください。

余談: aria-*をpickするだけのパターン

以上がaria-*とそれ以外のオブジェクトに分ける方法ですが、aria-*のみをpickするだけで良い場合は以下のコードのようにかなりシンプルに実装できます。

aria-*のみをpickするutility
import { pick } from 'lodash-es';

/**
 * propsからaria-*属性を抽出する
 * @param props - props
 */
export const pickAriaProps = <P extends object>(props: P) => {
  const ariaKeys = Object.keys(props).filter(
    (key): key is Extract<keyof P, `aria-${string}`> => key.startsWith('aria-')
  );

  return pick(props, ariaKeys);
};

今回の例でもあえてotherPropsを使用していますが、それを使わず分割前のrestPropsを使えば条件分岐による型確定はできますので、以下のように書き換えることは可能です。

pickAriaPropsで実装
 export const InputToggleButtonGroup: FC<InputToggleButtonGroupProps> = ({
   buttons,
   ...restProps
 }) => {
-  const { ariaProps, otherProps: conditionalProps } =
-    separateAriaProps(restProps);
 
   return (
     <ToggleButtonGroup
       color="primary"
       value={conditionalProps.value}
       exclusive={!conditionalProps.multiple}
-      {...ariaProps}
+      {...pickAriaProps(restProps)}
       onChange={(_, newValue) => {
-        if (conditionalProps.multiple) {
-          conditionalProps.onChangeValue(newValue as number[]);
-        } else {
-          conditionalProps.onChangeValue(newValue as number | null);
-        }
+        if (restProps.multiple) {
+          restProps.onChangeValue(newValue as number[]);
+        } else {
+          restProps.onChangeValue(newValue as number | null);
+        }
       }}
     >
       {buttons.map((button) => {
-        const { ariaProps } = separateAriaProps(button);
         const { value } = button;
         const content = 'icon' in button ? button.icon : button.label;
         return (
-          <ToggleButton key={value} value={value} {...ariaProps}>
+          <ToggleButton key={value} value={value} {...pickAriaProps(button)}>
             {content}
           </ToggleButton>
         );
       })}
     </ToggleButtonGroup>
   );
 };

otherPropsがあると良いケースはotherPropsだけでスプレッド演算で展開したいケースですが、これをスプレッド演算する場合はariaPropsも同じコンポーネントにスプレッドしていることが多く、中々発生しないケースなのかなと思いました。とは言えあるに越したことはないと思うので一応otherPropsも返すパターンを実装しました。

終わりに

以上がpropsからaria-*のみを抽出するutilityの作成方法でした。unionなオブジェクト型も考慮して実装しましたが、単純にaria-*のみをpickするだけであればその辺考慮する必要はなかったのかなと思いました。よって普段はシンプルなpickAriaPropsで事足りると思っていて、もしotherPropsも必要になった時に最初のパターンを参考にして貰えればと思います。
aria-*周りのバケツリレーに困っている方の参考になれば幸いです。

GitHubで編集を提案

Discussion