shadcn/ui の Dialog 内で Select を用いると SelectContent にフォーカスされない

shadcn/ui を利用してアプリケーションを作成していると、Dialog コンポーネント内で Select コンポーネントを使用する際、SelectContent にフォーカスが当たらず、選択肢の開閉時にエラーが発生するという問題に直面することがあります。  
本記事では、この現象の原因と解決方法、そして Dialog 内で正しく動作する SelectContent の実装例について紹介します。

## 事象の詳細

Dialog 内で Select コンポーネントを利用して選択を行うと、以下のような現象が発生します。

- **エラーメッセージ:** Select の開閉時にエラーが表示される
- **フォーカスの不具合:** 選択肢が表示されても、SelectContent にフォーカスが当たらない

```
Blocked aria-hidden on an element because its descendant retained focus. 
The focus must not be hidden from assistive technology users. 
Avoid using aria-hidden on a focused element or its ancestor. 
Consider using the inert attribute instead, which will also prevent focus. 
For more details, see the aria-hidden section of the WAI-ARIA specification at https://w3c.github.io/aria/#aria-hidden.
```

詳細な仕様は下記の公式ドキュメントを参照してください。  
- [Dialog コンポーネント](https://ui.shadcn.com/docs/components/dialog)  
- [Select コンポーネント](https://ui.shadcn.com/docs/components/select)

## 原因の特定

原因は、shadcn/ui の標準実装にあります。  
具体的には、SelectContent の内部で `SelectPrimitive.Portal` が使用されており、これにより SelectContent が Dialog の外側にある DOM ツリー上に作成されてしまいます。  
その結果、Dialog のフォーカスマネジメントの仕組みによって、正しくフォーカスが当たらなくなるという問題が発生しています。

## 解決策

この問題を解決するためには、Dialog 内で利用する Select コンポーネント専用に、Portal を使用しない実装を追加する必要があります。  
shadcn/ui の標準実装を元に、Dialog 向けの SelectContent を以下のように実装することで、問題を解決できます。

### 修正前

```tsx
const SelectContent = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
  <SelectPrimitive.Portal>
    <SelectPrimitive.Content
      ref={ref}
      className={cn(
        "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
        position === "popper" &&
          "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
        className
      )}
      position={position}
      {...props}
    >
      <SelectScrollUpButton />
      <SelectPrimitive.Viewport
        className={cn(
          "p-1",
          position === "popper" &&
            "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
        )}
      >
        {children}
      </SelectPrimitive.Viewport>
      <SelectScrollDownButton />
    </SelectPrimitive.Content>
  </SelectPrimitive.Portal>
));
```

### 追加する SelectContent の実装例

Portal を除去し、Dialog 内の DOM ツリーに直接レンダリングすることで、フォーカスの問題を解決します。

```tsx
const SelectContentForDialog = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
  <SelectPrimitive.Content
    ref={ref}
    className={cn(
      "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
      position === "popper" &&
        "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
      className
    )}
    position={position}
    {...props}
  >
    <SelectScrollUpButton />
    <SelectPrimitive.Viewport
      className={cn(
        "p-1",
        position === "popper" &&
          "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
      )}
    >
      {children}
    </SelectPrimitive.Viewport>
    <SelectScrollDownButton />
  </SelectPrimitive.Content>
));
```

この実装では、Portal コンポーネントを使用していないため、SelectContentForDialog は Dialog の子要素として直接 DOM に配置され、フォーカス管理が正常に行われるようになります。

## 注意点

この方法は Portal を削除するため、document.body 直下に SelectContent がマウントされず、Dialog 内にマウントされます。副作用もあり、Dialog の描画領域を超えて SelectContent を表示するのが難しくなります。

## まとめ

Dialog 内で Select コンポーネントを使用した際に発生するフォーカスの問題は、SelectContent が Portal を利用していることに起因していました。  
Dialog の外にレンダリングされると、Dialog のフォーカスマネジメントから外れてしまい、エラーや意図しない挙動が発生します。  
今回紹介した修正方法では、Dialog 向けに Portal を使用しない SelectContent を実装することで、この問題を解消しています。