Closed15

queryParamsと連動するTabがほしい

ピン留めされたアイテム
hajimismhajimism

全体像

TabWithRouter.tsx
"use client";

import { Route } from "next";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { ComponentPropsWithoutRef, ElementRef, FC, forwardRef } from "react";

import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";

import { TabItem } from "./type";

type Props = {
  tabItems: TabItem[];
  param?: string;
};

export const TabWithRouter: FC<Props> = ({ tabItems, param = "tab" }) => {
  const searchParams = useSearchParams();

  if (!tabItems[0]) return <></>;
  const tabParam = searchParams.get(param) ?? tabItems[0].value;

  return (
    <Tabs value={tabParam}>
      <TabsList>
        {tabItems.map(({ value, label }) => (
          <TabsTriggerWithRouter value={value} key={value} param={param}>
            {label}
          </TabsTriggerWithRouter>
        ))}
      </TabsList>

      <>
        {tabItems.map(({ value, content }) => (
          <TabsContent value={value} key={value}>
            {content}
          </TabsContent>
        ))}
      </>
    </Tabs>
  );
};

const TabsTriggerWithRouter = forwardRef<
  ElementRef<typeof TabsTrigger>,
  ComponentPropsWithoutRef<typeof TabsTrigger> & { param?: string }
>(({ param = "tab", ...props }, ref) => {
  const router = useRouter();
  // Bug: pathname should be Route when experimental.typedRoutes === true
  const pathname = usePathname() as Route;
  const onClick = () => router.push(`${pathname}?${param}=${props.value}`);

  return <TabsTrigger ref={ref} {...props} onClick={onClick} />;
});
TabsTriggerWithRouter.displayName = "TabsTriggerWithRouter";

types.ts
import { ReactNode } from "react";

export type TabItem = {
  value: string;
  label: string;
  content: ReactNode;
};
hajimismhajimism

TabsonValueChangeというメソッドがあったのでこっちを使うほうが良いと思った

"use client";

import { Route } from "next";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { ComponentPropsWithoutRef, ElementRef, FC, forwardRef } from "react";

import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";

import { TabItem } from "./type";

type Props = {
  tabItems: TabItem[];
  param?: string;
};

export const TabWithRouter: FC<Props> = ({ tabItems, param = "tab" }) => {
  if (!tabItems[0]) return <></>;
  const defaultValue = tabItems[0].value;

  return (
    <TabsWithRouter defaultValue={defaultValue} param={param}>
      <TabsList>
        {tabItems.map(({ value, label }) => (
          <TabsTrigger value={value} key={value}>
            {label}
          </TabsTrigger>
        ))}
      </TabsList>

      <>
        {tabItems.map(({ value, content }) => (
          <TabsContent value={value} key={value}>
            {content}
          </TabsContent>
        ))}
      </>
    </TabsWithRouter>
  );
};

const TabsWithRouter = forwardRef<
  ElementRef<typeof Tabs>,
  ComponentPropsWithoutRef<typeof Tabs> & {
    param?: string;
    defaultValue: string;
  }
>(({ param = "tab", defaultValue, ...props }, ref) => {
  const router = useRouter();
  const searchParams = useSearchParams();
  // Bug: pathname should be Route when experimental.typedRoutes === true
  const pathname = usePathname() as Route;

  const tabValue = searchParams.get(param) ?? defaultValue;

  const onValueChange = (value: string) =>
    router.push(`${pathname}?${param}=${value}`);

  return (
    <Tabs ref={ref} value={tabValue} onValueChange={onValueChange} {...props} />
  );
});
TabsWithRouter.displayName = "TabsWithRouter";

hajimismhajimism

defaultValueを強制する必要があるので、型を上書きしている

hajimismhajimism

Usageを見ると、TabsTriggerとTabsContentがvalueで連動していることが分かる

<Tabs defaultValue="account" className="w-[400px]">
  <TabsList>
    <TabsTrigger value="account">Account</TabsTrigger>
    <TabsTrigger value="password">Password</TabsTrigger>
  </TabsList>
  <TabsContent value="account">Make changes to your account here.</TabsContent>
  <TabsContent value="password">Change your password here.</TabsContent>
</Tabs>

hajimismhajimism

Accountをクリックしたら、例えば?tab=accountになってほしい。tabのところは選べるとなおよい。

hajimismhajimism

一旦、機能は置いておいて抽象化する

"use client";

import { FC, ReactNode, useMemo } from "react";

import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";

type TabsTriggerItem = {
  value: string;
  label: string;
};

type TabsContentItem = {
  value: string;
  content: ReactNode;
};

type TabItem = TabsTriggerItem & TabsContentItem;

const unpackTabItems = (tabItems: TabItem[]) => {
  const tab: { triggers: TabsTriggerItem[]; contents: TabsContentItem[] } = {
    triggers: [],
    contents: [],
  };

  tabItems.forEach(({ value, label, content }) => {
    tab.triggers = [...tab.triggers, { value, label }];
    tab.contents = [...tab.contents, { value, content }];
  });

  return tab;
};

type Props = {
  tabItems: TabItem[];
};

export const TabWithRouter: FC<Props> = ({ tabItems }) => {
  const { triggers, contents } = useMemo(
    () => unpackTabItems(tabItems),
    [tabItems]
  );

  return (
    <Tabs>
      <TabsList>
        {triggers.map(({ value, label }) => (
          <TabsTrigger value={value} key={value}>
            {label}
          </TabsTrigger>
        ))}
      </TabsList>

      <>
        {contents.map(({ value, content }) => (
          <TabsContent value={value} key={value}>
            {content}
          </TabsContent>
        ))}
      </>
    </Tabs>
  );
};

hajimismhajimism

unpackTabItemsは余計だったかもな、計算の回数が増えている

hajimismhajimism
"use client";

import { FC, ReactNode } from "react";

import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";

type TabsTriggerItem = {
  value: string;
  label: string;
};

type TabsContentItem = {
  value: string;
  content: ReactNode;
};

type TabItem = TabsTriggerItem & TabsContentItem;

type Props = {
  tabItems: TabItem[];
};

export const TabWithRouter: FC<Props> = ({ tabItems }) => {
  return (
    <Tabs>
      <TabsList>
        {tabItems.map(({ value, label }) => (
          <TabsTrigger value={value} key={value}>
            {label}
          </TabsTrigger>
        ))}
      </TabsList>

      <>
        {tabItems.map(({ value, content }) => (
          <TabsContent value={value} key={value}>
            {content}
          </TabsContent>
        ))}
      </>
    </Tabs>
  );
};

hajimismhajimism

TabsTriggerのonClickでrouterが変わってくれれば良い
TabsTriggerだけ切り出せばいいか

hajimismhajimism

これでいいのかな?

const TabsTriggerWithRouter = forwardRef<
  ElementRef<typeof TabsTrigger>,
  ComponentPropsWithoutRef<typeof TabsTrigger> & { param?: string }
>(({ param = "tab", ...props }, ref) => {
  const router = useRouter();
  // Bug: pathname should be Route when experimental.typedRoutes === true
  const pathname = usePathname() as Route;
  const onClick = () => router.push(`${pathname}?${param}=${props.value}`);

  return <TabsTrigger ref={ref} {...props} onClick={onClick} />;
});
TabsTriggerWithRouter.displayName = "TabsTriggerWithRouter";

hajimismhajimism

一応tabを押せばrouterが変わる。
でもTabs自体はURLを見ていないのでリロードしたらだめになる。
Tabsのvalueに値を渡したい

hajimismhajimism

useSearchParams()で渡してみる。tabの値がないときは最初のやつってことで。

export const TabWithRouter: FC<Props> = ({ tabItems }) => {
  const searchParams = useSearchParams();

  if (!tabItems[0]) return <></>;
  const tab = searchParams.get("tab") ?? tabItems[0].value;

  return (
    <Tabs value={tab}>
      <TabsList>
        {tabItems.map(({ value, label }) => (
          <TabsTriggerWithRouter value={value} key={value}>
            {label}
          </TabsTriggerWithRouter>
        ))}
      </TabsList>

      <>
        {tabItems.map(({ value, content }) => (
          <TabsContent value={value} key={value}>
            {content}
          </TabsContent>
        ))}
      </>
    </Tabs>
  );
};

hajimismhajimism

外側からparamを指定できるようにした

export const TabWithRouter: FC<Props> = ({ tabItems, param = "tab" }) => {
  const searchParams = useSearchParams();

  if (!tabItems[0]) return <></>;
  const tabParam = searchParams.get(param) ?? tabItems[0].value;

  return (
    <Tabs value={tabParam}>
      <TabsList>
        {tabItems.map(({ value, label }) => (
          <TabsTriggerWithRouter value={value} key={value} param={param}>
            {label}
          </TabsTriggerWithRouter>
        ))}
      </TabsList>

      <>
        {tabItems.map(({ value, content }) => (
          <TabsContent value={value} key={value}>
            {content}
          </TabsContent>
        ))}
      </>
    </Tabs>
  );
};

このスクラップは2023/06/11にクローズされました