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を作ってみたので、それについて記事にまとめました。
aria-*
とそれ以外に分けるutilityの作成
propsからまず型でpropsからaria-*
のキーのみを抽出したいと思います。Extract<keyof P,'aria-${string}'>
でaria-*
のキーだけ抽出できるため、これをMapped Typesに当て込めるとaria-*
のみのオブジェクトが取得できます。
/** aria-*を抽出する */
type PickAriaProps<P extends object> = {
[K in Extract<keyof P, `aria-${string}`>]: P[K];
};
以下の記事で説明してますが、unionなオブジェクトの場合はそれぞれに対してPickさせる必要があるので以下のようにDistributive Conditional Typeを使って分配させる必要があります。
/** unionなオブジェクトに対して、それぞれPickする */
type DistributivePickAriaProps<P extends object> = P extends any
? PickAriaProps<P>
: never;
今回は念の為aria-*
以外のオブジェクトも返そうと思っていますが、これは逆をすれば良いので以下のようなコードになります。
/** 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な情報を維持できています。
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するだけで良い場合は以下のコードのようにかなりシンプルに実装できます。
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
を使えば条件分岐による型確定はできますので、以下のように書き換えることは可能です。
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-*
周りのバケツリレーに困っている方の参考になれば幸いです。
Discussion