Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions packages/frontend/app/components/bottom-navigation.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { Gift, QrCode, Scan, Send } from "lucide-react";
import type * as React from "react";
import {
MemoryRouter,
Route,
Routes,
type To,
useLocation,
} from "react-router";

import "~/app.css";
import {
BottomNavigation,
BottomNavigationItem,
} from "~/components/ui/bottom-navigation";

export default {
title: "Components/BottomNavigation",
};

type StoryFrameProps = {
children: React.ReactNode;
initialEntry?: string;
};

function StoryFrame({ children, initialEntry = "/send" }: StoryFrameProps) {
return (
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="*" element={<StoryContent>{children}</StoryContent>} />
</Routes>
</MemoryRouter>
);
}

function StoryContent({ children }: { children: React.ReactNode }) {
const location = useLocation();
return (
<div className="relative min-h-screen bg-background">
<div className="p-24">
<p className="text-ui-13 text-muted-foreground">
現在のパス: {location.pathname}
</p>
</div>
{children}
</div>
);
}

const ICON_SIZE = 20;

type NavItem = {
icon: React.ReactNode;
label: string;
to: To;
};

const navItems: NavItem[] = [
{ icon: <Send size={ICON_SIZE} />, label: "送る", to: "/send" },
{ icon: <Scan size={ICON_SIZE} />, label: "スキャン", to: "/scan" },
{ icon: <QrCode size={ICON_SIZE} />, label: "マイコード", to: "/mycode" },
{ icon: <Gift size={ICON_SIZE} />, label: "出品", to: "/listing" },
];

export const Default = () => {
return (
<StoryFrame initialEntry="/send">
<BottomNavigation>
{navItems.map((item) => (
<BottomNavigationItem
key={item.label}
icon={item.icon}
label={item.label}
to={item.to}
/>
))}
</BottomNavigation>
</StoryFrame>
);
};

Default.storyName = "Default (1st Active)";

export const ThirdActive = () => {
return (
<StoryFrame initialEntry="/mycode">
<BottomNavigation>
{navItems.map((item) => (
<BottomNavigationItem
key={item.label}
icon={item.icon}
label={item.label}
to={item.to}
/>
))}
</BottomNavigation>
</StoryFrame>
);
};

ThirdActive.storyName = "3rd Item Active";

export const WithDisabled = () => {
return (
<StoryFrame initialEntry="/send">
<BottomNavigation>
{navItems.map((item, i) => (
<BottomNavigationItem
key={item.label}
disabled={i === 3}
icon={item.icon}
label={item.label}
to={item.to}
/>
))}
</BottomNavigation>
</StoryFrame>
);
};

WithDisabled.storyName = "With Disabled Item";
84 changes: 84 additions & 0 deletions packages/frontend/app/components/ui/bottom-navigation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import * as React from "react";
import { NavLink, type To } from "react-router";

import { cn } from "~/lib/utils";

export interface BottomNavigationProps
extends React.HTMLAttributes<HTMLElement> {
children: React.ReactNode;
}

export const BottomNavigation = React.forwardRef<
HTMLElement,
BottomNavigationProps
>(({ className, children, ...props }, ref) => {
return (
<nav
ref={ref}
aria-label="メインナビゲーション"
className={cn(
"fixed bottom-0 left-0 z-20 flex w-full justify-center pb-[env(safe-area-inset-bottom)]",
className,
)}
{...props}
>
<div className="mb-12 flex items-center gap-4 rounded-full bg-primary px-8 py-8 shadow-elevation-1">
{children}
</div>
</nav>
);
});
BottomNavigation.displayName = "BottomNavigation";

export interface BottomNavigationItemProps {
"aria-label"?: string;
className?: string;
disabled?: boolean;
icon: React.ReactNode;
label: string;
to: To;
}

export const BottomNavigationItem = React.forwardRef<
HTMLAnchorElement,
BottomNavigationItemProps
>(
(
{ "aria-label": ariaLabel, className, disabled = false, icon, label, to },
ref,
) => {
if (disabled) {
return (
<span
aria-disabled="true"
className={cn(
"flex w-[72px] flex-col items-center gap-2 rounded-full px-8 py-6 text-primary-foreground opacity-40",
className,
)}
>
{icon}
<span className="text-ui-10 font-medium">{label}</span>
</span>
);
}

return (
<NavLink
ref={ref}
to={to}
aria-label={ariaLabel}
className={({ isActive }) =>
cn(
"flex w-[72px] flex-col items-center gap-2 rounded-full px-8 py-6 text-primary-foreground transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isActive && "bg-alpha-black-25",
className,
)
}
>
{icon}
<span className="text-ui-10 font-medium">{label}</span>
</NavLink>
);
},
);
BottomNavigationItem.displayName = "BottomNavigationItem";