feat: 기본 레이아웃 설정, 스타일 설정, 메뉴 세팅 (ui는 kamco 프로젝트 기반)
This commit is contained in:
22
web-app/app/shared/components/menu/LayoutMenu.tsx
Normal file
22
web-app/app/shared/components/menu/LayoutMenu.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
|
||||
import { Menu, type MenuItemChildrenType, type MenuItemType } from './Menu';
|
||||
|
||||
interface Props {
|
||||
items: MenuItemType[];
|
||||
}
|
||||
|
||||
export const LayoutMenu = ({ items }: Props) => {
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onSelectionChange = useCallback(
|
||||
(menu: MenuItemChildrenType) => {
|
||||
navigate(menu.menuUrl);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
return <Menu currentPath={pathname} items={items} onSelectionChange={onSelectionChange} />;
|
||||
};
|
||||
124
web-app/app/shared/components/menu/Menu.tsx
Normal file
124
web-app/app/shared/components/menu/Menu.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useCallback, useEffect, useId, useState } from 'react';
|
||||
|
||||
export type MenuItemChildrenType = {
|
||||
id: string;
|
||||
name: string;
|
||||
menuUrl: string;
|
||||
};
|
||||
export type MenuItemType = {
|
||||
id: string;
|
||||
name: string;
|
||||
menuUrl?: null;
|
||||
children: MenuItemChildrenType[];
|
||||
};
|
||||
export interface MenuProps {
|
||||
items: MenuItemType[];
|
||||
onSelectionChange: (menu: MenuItemChildrenType) => void;
|
||||
currentPath: string;
|
||||
}
|
||||
export const Menu = (props: MenuProps) => {
|
||||
const { items, currentPath, onSelectionChange } = props;
|
||||
const id = useId();
|
||||
|
||||
const getCurrentPathItem = useCallback(() => {
|
||||
return items.find((i) => i.children.some((c) => currentPath.includes(c.menuUrl)));
|
||||
}, [items, currentPath]);
|
||||
|
||||
const [expandedItemKeys, setExpandedItemKeys] = useState<MenuItemType['id'][]>(() => {
|
||||
const item = getCurrentPathItem();
|
||||
return item ? [item.id] : [];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const item = getCurrentPathItem();
|
||||
setExpandedItemKeys((prev) => {
|
||||
if (item && prev.length === 1 && prev[0] === item.id) {
|
||||
return prev;
|
||||
}
|
||||
return item ? [item.id] : [];
|
||||
});
|
||||
}, [getCurrentPathItem]);
|
||||
|
||||
return (
|
||||
<nav aria-label="메인 메뉴">
|
||||
{Array.isArray(items) && items.length > 0 && (
|
||||
<ul>
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="border-b font-medium border-dabeeo-gray-be">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={
|
||||
expandedItemKeys.some((expandedItemKey) => expandedItemKey === item.id) ? 'true' : 'false'
|
||||
}
|
||||
aria-controls={`${id}-panel-${item.id}`}
|
||||
className="appearance-none peer w-full h-10 px-5 text-sm flex items-center justify-between cursor-pointer aria-expanded:bg-white transition-colors focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
|
||||
onClick={() => {
|
||||
setExpandedItemKeys((prev) => {
|
||||
const isExpanded = prev.some((key) => key === item.id);
|
||||
if (isExpanded) {
|
||||
return prev.filter((key) => key !== item.id);
|
||||
} else {
|
||||
return [...prev, item.id];
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
<div
|
||||
className="data-[expanded=true]:rotate-180 transition"
|
||||
data-expanded={
|
||||
expandedItemKeys.some((expandedItemKey) => expandedItemKey === item.id) ? 'true' : undefined
|
||||
}
|
||||
>
|
||||
{expandedItemKeys.some((expandedItemKey) => expandedItemKey === item.id) ? (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="7" width="2" height="10" transform="rotate(-90 1 7)" fill="#222222" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 5H11V7H7V11H5V7H1V5H5V1H7V5Z" fill="#222222" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{Array.isArray(item.children) && item.children.length > 0 && (
|
||||
<div
|
||||
id={`${id}-panel-${item.id}`}
|
||||
aria-hidden={!expandedItemKeys.some((key) => key === item.id)}
|
||||
data-expanded={expandedItemKeys.some((key) => key === item.id) || undefined}
|
||||
className="group/submenu grid grid-rows-[0fr] data-[expanded]:grid-rows-[1fr] transition-[grid-template-rows]"
|
||||
>
|
||||
<ul className="overflow-hidden opacity-0 group-data-[expanded]/submenu:opacity-100 transition-opacity duration-500">
|
||||
{item.children.map((child) => (
|
||||
<li key={`menu-${item.id}-child-${child.id}`}>
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={expandedItemKeys.some((key) => key === item.id) ? 0 : -1}
|
||||
aria-current={currentPath.includes(child.menuUrl) ? 'page' : undefined}
|
||||
className="flex h-10 cursor-pointer items-center gap-2.5 pl-8 text-sm font-medium bg-primary-tertiary02 aria-[current=page]:bg-dabeeo-gray-da focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500"
|
||||
onClick={() => {
|
||||
setExpandedItemKeys([item.id]);
|
||||
onSelectionChange(child);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setExpandedItemKeys([item.id]);
|
||||
onSelectionChange(child);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{child.name}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
2
web-app/app/shared/components/menu/index.ts
Normal file
2
web-app/app/shared/components/menu/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Menu } from './Menu';
|
||||
export type { MenuProps, MenuItemType, MenuItemChildrenType } from './Menu';
|
||||
Reference in New Issue
Block a user