mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
chore: initial Untitled UI Vite scaffold with FontAwesome Pro
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { X as CloseIcon, Menu02 } from "@untitledui/icons";
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Dialog as AriaDialog,
|
||||
DialogTrigger as AriaDialogTrigger,
|
||||
Modal as AriaModal,
|
||||
ModalOverlay as AriaModalOverlay,
|
||||
} from "react-aria-components";
|
||||
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
export const MobileNavigationHeader = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<AriaDialogTrigger>
|
||||
<header className="flex h-16 items-center justify-between border-b border-secondary bg-primary py-3 pr-2 pl-4 lg:hidden">
|
||||
<UntitledLogo />
|
||||
|
||||
<AriaButton
|
||||
aria-label="Expand navigation menu"
|
||||
className="group flex items-center justify-center rounded-lg bg-primary p-2 text-fg-secondary outline-focus-ring hover:bg-primary_hover hover:text-fg-secondary_hover focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
>
|
||||
<Menu02 className="size-6 transition duration-200 ease-in-out group-aria-expanded:opacity-0" />
|
||||
<CloseIcon className="absolute size-6 opacity-0 transition duration-200 ease-in-out group-aria-expanded:opacity-100" />
|
||||
</AriaButton>
|
||||
</header>
|
||||
|
||||
<AriaModalOverlay
|
||||
isDismissable
|
||||
className={({ isEntering, isExiting }) =>
|
||||
cx(
|
||||
"fixed inset-0 z-50 cursor-pointer bg-overlay/70 pr-16 backdrop-blur-md lg:hidden",
|
||||
isEntering && "duration-300 ease-in-out animate-in fade-in",
|
||||
isExiting && "duration-200 ease-in-out animate-out fade-out",
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ state }) => (
|
||||
<>
|
||||
<AriaButton
|
||||
aria-label="Close navigation menu"
|
||||
onPress={() => state.close()}
|
||||
className="fixed top-3 right-2 flex cursor-pointer items-center justify-center rounded-lg p-2 text-fg-white/70 outline-focus-ring hover:bg-white/10 hover:text-fg-white focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
>
|
||||
<CloseIcon className="size-6" />
|
||||
</AriaButton>
|
||||
|
||||
<AriaModal className="w-full cursor-auto will-change-transform">
|
||||
<AriaDialog className="h-dvh outline-hidden focus:outline-hidden">{children}</AriaDialog>
|
||||
</AriaModal>
|
||||
</>
|
||||
)}
|
||||
</AriaModalOverlay>
|
||||
</AriaDialogTrigger>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { FC, HTMLAttributes } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import type { Placement } from "@react-types/overlays";
|
||||
import { BookOpen01, ChevronSelectorVertical, LogOut01, Plus, Settings01, User01 } from "@untitledui/icons";
|
||||
import { useFocusManager } from "react-aria";
|
||||
import type { DialogProps as AriaDialogProps } from "react-aria-components";
|
||||
import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
|
||||
import { AvatarLabelGroup } from "@/components/base/avatar/avatar-label-group";
|
||||
import { Button } from "@/components/base/buttons/button";
|
||||
import { RadioButtonBase } from "@/components/base/radio-buttons/radio-buttons";
|
||||
import { useBreakpoint } from "@/hooks/use-breakpoint";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
type NavAccountType = {
|
||||
/** Unique identifier for the nav item. */
|
||||
id: string;
|
||||
/** Name of the account holder. */
|
||||
name: string;
|
||||
/** Email address of the account holder. */
|
||||
email: string;
|
||||
/** Avatar image URL. */
|
||||
avatar: string;
|
||||
/** Online status of the account holder. This is used to display the online status indicator. */
|
||||
status: "online" | "offline";
|
||||
};
|
||||
|
||||
const placeholderAccounts: NavAccountType[] = [
|
||||
{
|
||||
id: "olivia",
|
||||
name: "Olivia Rhye",
|
||||
email: "olivia@untitledui.com",
|
||||
avatar: "https://www.untitledui.com/images/avatars/olivia-rhye?fm=webp&q=80",
|
||||
status: "online",
|
||||
},
|
||||
{
|
||||
id: "sienna",
|
||||
name: "Sienna Hewitt",
|
||||
email: "sienna@untitledui.com",
|
||||
avatar: "https://www.untitledui.com/images/avatars/transparent/sienna-hewitt?bg=%23E0E0E0",
|
||||
status: "online",
|
||||
},
|
||||
];
|
||||
|
||||
export const NavAccountMenu = ({
|
||||
className,
|
||||
selectedAccountId = "olivia",
|
||||
...dialogProps
|
||||
}: AriaDialogProps & { className?: string; accounts?: NavAccountType[]; selectedAccountId?: string }) => {
|
||||
const focusManager = useFocusManager();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
focusManager?.focusNext({ tabbable: true, wrap: true });
|
||||
break;
|
||||
case "ArrowUp":
|
||||
focusManager?.focusPrevious({ tabbable: true, wrap: true });
|
||||
break;
|
||||
}
|
||||
},
|
||||
[focusManager],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const element = dialogRef.current;
|
||||
if (element) {
|
||||
element.addEventListener("keydown", onKeyDown);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (element) {
|
||||
element.removeEventListener("keydown", onKeyDown);
|
||||
}
|
||||
};
|
||||
}, [onKeyDown]);
|
||||
|
||||
return (
|
||||
<AriaDialog
|
||||
{...dialogProps}
|
||||
ref={dialogRef}
|
||||
className={cx("w-66 rounded-xl bg-secondary_alt shadow-lg ring ring-secondary_alt outline-hidden", className)}
|
||||
>
|
||||
<div className="rounded-xl bg-primary ring-1 ring-secondary">
|
||||
<div className="flex flex-col gap-0.5 py-1.5">
|
||||
<NavAccountCardMenuItem label="View profile" icon={User01} shortcut="⌘K->P" />
|
||||
<NavAccountCardMenuItem label="Account settings" icon={Settings01} shortcut="⌘S" />
|
||||
<NavAccountCardMenuItem label="Documentation" icon={BookOpen01} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 border-t border-secondary py-1.5">
|
||||
<div className="px-3 pt-1.5 pb-1 text-xs font-semibold text-tertiary">Switch account</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5 px-1.5">
|
||||
{placeholderAccounts.map((account) => (
|
||||
<button
|
||||
key={account.id}
|
||||
className={cx(
|
||||
"relative w-full cursor-pointer rounded-md px-2 py-1.5 text-left outline-focus-ring hover:bg-primary_hover focus:z-10 focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||
account.id === selectedAccountId && "bg-primary_hover",
|
||||
)}
|
||||
>
|
||||
<AvatarLabelGroup status="online" size="md" src={account.avatar} title={account.name} subtitle={account.email} />
|
||||
|
||||
<RadioButtonBase isSelected={account.id === selectedAccountId} className="absolute top-2 right-2" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 px-2 pt-0.5 pb-2">
|
||||
<Button iconLeading={Plus} color="secondary" size="sm">
|
||||
Add account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-1 pb-1.5">
|
||||
<NavAccountCardMenuItem label="Sign out" icon={LogOut01} shortcut="⌥⇧Q" />
|
||||
</div>
|
||||
</AriaDialog>
|
||||
);
|
||||
};
|
||||
|
||||
const NavAccountCardMenuItem = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
shortcut,
|
||||
...buttonProps
|
||||
}: {
|
||||
icon?: FC<{ className?: string }>;
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
} & HTMLAttributes<HTMLButtonElement>) => {
|
||||
return (
|
||||
<button {...buttonProps} className={cx("group/item w-full cursor-pointer px-1.5 focus:outline-hidden", buttonProps.className)}>
|
||||
<div
|
||||
className={cx(
|
||||
"flex w-full items-center justify-between gap-3 rounded-md p-2 group-hover/item:bg-primary_hover",
|
||||
// Focus styles.
|
||||
"outline-focus-ring group-focus-visible/item:outline-2 group-focus-visible/item:outline-offset-2",
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-2 text-sm font-semibold text-secondary group-hover/item:text-secondary_hover">
|
||||
{Icon && <Icon className="size-5 text-fg-quaternary" />} {label}
|
||||
</div>
|
||||
|
||||
{shortcut && (
|
||||
<kbd className="flex rounded px-1 py-px font-body text-xs font-medium text-tertiary ring-1 ring-secondary ring-inset">{shortcut}</kbd>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const NavAccountCard = ({
|
||||
popoverPlacement,
|
||||
selectedAccountId = "olivia",
|
||||
items = placeholderAccounts,
|
||||
}: {
|
||||
popoverPlacement?: Placement;
|
||||
selectedAccountId?: string;
|
||||
items?: NavAccountType[];
|
||||
}) => {
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const isDesktop = useBreakpoint("lg");
|
||||
|
||||
const selectedAccount = placeholderAccounts.find((account) => account.id === selectedAccountId);
|
||||
|
||||
if (!selectedAccount) {
|
||||
console.warn(`Account with ID ${selectedAccountId} not found in <NavAccountCard />`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={triggerRef} className="relative flex items-center gap-3 rounded-xl p-3 ring-1 ring-secondary ring-inset">
|
||||
<AvatarLabelGroup
|
||||
size="md"
|
||||
src={selectedAccount.avatar}
|
||||
title={selectedAccount.name}
|
||||
subtitle={selectedAccount.email}
|
||||
status={selectedAccount.status}
|
||||
/>
|
||||
|
||||
<div className="absolute top-1.5 right-1.5">
|
||||
<AriaDialogTrigger>
|
||||
<AriaButton className="flex cursor-pointer items-center justify-center rounded-md p-1.5 text-fg-quaternary outline-focus-ring transition duration-100 ease-linear hover:bg-primary_hover hover:text-fg-quaternary_hover focus-visible:outline-2 focus-visible:outline-offset-2 pressed:bg-primary_hover pressed:text-fg-quaternary_hover">
|
||||
<ChevronSelectorVertical className="size-4 shrink-0" />
|
||||
</AriaButton>
|
||||
<AriaPopover
|
||||
placement={popoverPlacement ?? (isDesktop ? "right bottom" : "top right")}
|
||||
triggerRef={triggerRef}
|
||||
offset={8}
|
||||
className={({ isEntering, isExiting }) =>
|
||||
cx(
|
||||
"origin-(--trigger-anchor-point) will-change-transform",
|
||||
isEntering &&
|
||||
"duration-150 ease-out animate-in fade-in placement-right:slide-in-from-left-0.5 placement-top:slide-in-from-bottom-0.5 placement-bottom:slide-in-from-top-0.5",
|
||||
isExiting &&
|
||||
"duration-100 ease-in animate-out fade-out placement-right:slide-out-to-left-0.5 placement-top:slide-out-to-bottom-0.5 placement-bottom:slide-out-to-top-0.5",
|
||||
)
|
||||
}
|
||||
>
|
||||
<NavAccountMenu selectedAccountId={selectedAccountId} accounts={items} />
|
||||
</AriaPopover>
|
||||
</AriaDialogTrigger>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { FC, MouseEventHandler } from "react";
|
||||
import { Pressable } from "react-aria-components";
|
||||
import { Tooltip } from "@/components/base/tooltip/tooltip";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
const styles = {
|
||||
md: {
|
||||
root: "size-10",
|
||||
icon: "size-5",
|
||||
},
|
||||
lg: {
|
||||
root: "size-12",
|
||||
icon: "size-6",
|
||||
},
|
||||
};
|
||||
|
||||
interface NavItemButtonProps {
|
||||
/** Whether the collapsible nav item is open. */
|
||||
open?: boolean;
|
||||
/** URL to navigate to when the button is clicked. */
|
||||
href?: string;
|
||||
/** Label text for the button. */
|
||||
label: string;
|
||||
/** Icon component to display. */
|
||||
icon: FC<{ className?: string }>;
|
||||
/** Whether the button is currently active. */
|
||||
current?: boolean;
|
||||
/** Size of the button. */
|
||||
size?: "md" | "lg";
|
||||
/** Handler for click events. */
|
||||
onClick?: MouseEventHandler;
|
||||
/** Additional CSS classes to apply to the button. */
|
||||
className?: string;
|
||||
/** Placement of the tooltip. */
|
||||
tooltipPlacement?: "top" | "right" | "bottom" | "left";
|
||||
}
|
||||
|
||||
export const NavItemButton = ({
|
||||
current: current,
|
||||
label,
|
||||
href,
|
||||
icon: Icon,
|
||||
size = "md",
|
||||
className,
|
||||
tooltipPlacement = "right",
|
||||
onClick,
|
||||
}: NavItemButtonProps) => {
|
||||
return (
|
||||
<Tooltip title={label} placement={tooltipPlacement}>
|
||||
<Pressable>
|
||||
<a
|
||||
href={href}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className={cx(
|
||||
"relative flex w-full cursor-pointer items-center justify-center rounded-md bg-primary p-2 text-fg-quaternary outline-focus-ring transition duration-100 ease-linear select-none hover:bg-primary_hover hover:text-fg-quaternary_hover focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||
current && "bg-active text-fg-quaternary_hover hover:bg-secondary_hover",
|
||||
styles[size].root,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden="true" className={cx("shrink-0 transition-inherit-all", styles[size].icon)} />
|
||||
</a>
|
||||
</Pressable>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { FC, HTMLAttributes, MouseEventHandler, ReactNode } from "react";
|
||||
import { ChevronDown, Share04 } from "@untitledui/icons";
|
||||
import { Link as AriaLink } from "react-aria-components";
|
||||
import { Badge } from "@/components/base/badges/badges";
|
||||
import { cx, sortCx } from "@/utils/cx";
|
||||
|
||||
const styles = sortCx({
|
||||
root: "group relative flex w-full cursor-pointer items-center rounded-md bg-primary outline-focus-ring transition duration-100 ease-linear select-none hover:bg-primary_hover focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||
rootSelected: "bg-active hover:bg-secondary_hover",
|
||||
});
|
||||
|
||||
interface NavItemBaseProps {
|
||||
/** Whether the nav item shows only an icon. */
|
||||
iconOnly?: boolean;
|
||||
/** Whether the collapsible nav item is open. */
|
||||
open?: boolean;
|
||||
/** URL to navigate to when the nav item is clicked. */
|
||||
href?: string;
|
||||
/** Type of the nav item. */
|
||||
type: "link" | "collapsible" | "collapsible-child";
|
||||
/** Icon component to display. */
|
||||
icon?: FC<HTMLAttributes<HTMLOrSVGElement>>;
|
||||
/** Badge to display. */
|
||||
badge?: ReactNode;
|
||||
/** Whether the nav item is currently active. */
|
||||
current?: boolean;
|
||||
/** Whether to truncate the label text. */
|
||||
truncate?: boolean;
|
||||
/** Handler for click events. */
|
||||
onClick?: MouseEventHandler;
|
||||
/** Content to display. */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const NavItemBase = ({ current, type, badge, href, icon: Icon, children, truncate = true, onClick }: NavItemBaseProps) => {
|
||||
const iconElement = Icon && <Icon aria-hidden="true" className="mr-2 size-5 shrink-0 text-fg-quaternary transition-inherit-all" />;
|
||||
|
||||
const badgeElement =
|
||||
badge && (typeof badge === "string" || typeof badge === "number") ? (
|
||||
<Badge className="ml-3" color="gray" type="pill-color" size="sm">
|
||||
{badge}
|
||||
</Badge>
|
||||
) : (
|
||||
badge
|
||||
);
|
||||
|
||||
const labelElement = (
|
||||
<span
|
||||
className={cx(
|
||||
"flex-1 text-md font-semibold text-secondary transition-inherit-all group-hover:text-secondary_hover",
|
||||
truncate && "truncate",
|
||||
current && "text-secondary_hover",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
const isExternal = href && href.startsWith("http");
|
||||
const externalIcon = isExternal && <Share04 className="size-4 stroke-[2.5px] text-fg-quaternary" />;
|
||||
|
||||
if (type === "collapsible") {
|
||||
return (
|
||||
<summary className={cx("px-3 py-2", styles.root, current && styles.rootSelected)} onClick={onClick}>
|
||||
{iconElement}
|
||||
|
||||
{labelElement}
|
||||
|
||||
{badgeElement}
|
||||
|
||||
<ChevronDown aria-hidden="true" className="ml-3 size-4 shrink-0 stroke-[2.5px] text-fg-quaternary in-open:-scale-y-100" />
|
||||
</summary>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "collapsible-child") {
|
||||
return (
|
||||
<AriaLink
|
||||
href={href!}
|
||||
target={isExternal ? "_blank" : "_self"}
|
||||
rel="noopener noreferrer"
|
||||
className={cx("py-2 pr-3 pl-10", styles.root, current && styles.rootSelected)}
|
||||
onClick={onClick}
|
||||
aria-current={current ? "page" : undefined}
|
||||
>
|
||||
{labelElement}
|
||||
{externalIcon}
|
||||
{badgeElement}
|
||||
</AriaLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AriaLink
|
||||
href={href!}
|
||||
target={isExternal ? "_blank" : "_self"}
|
||||
rel="noopener noreferrer"
|
||||
className={cx("px-3 py-2", styles.root, current && styles.rootSelected)}
|
||||
onClick={onClick}
|
||||
aria-current={current ? "page" : undefined}
|
||||
>
|
||||
{iconElement}
|
||||
{labelElement}
|
||||
{externalIcon}
|
||||
{badgeElement}
|
||||
</AriaLink>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { cx } from "@/utils/cx";
|
||||
import type { NavItemDividerType, NavItemType } from "../config";
|
||||
import { NavItemBase } from "./nav-item";
|
||||
|
||||
interface NavListProps {
|
||||
/** URL of the currently active item. */
|
||||
activeUrl?: string;
|
||||
/** Additional CSS classes to apply to the list. */
|
||||
className?: string;
|
||||
/** List of items to display. */
|
||||
items: (NavItemType | NavItemDividerType)[];
|
||||
}
|
||||
|
||||
export const NavList = ({ activeUrl, items, className }: NavListProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const activeItem = items.find((item) => item.href === activeUrl || item.items?.some((subItem) => subItem.href === activeUrl));
|
||||
const [currentItem, setCurrentItem] = useState(activeItem);
|
||||
|
||||
return (
|
||||
<ul className={cx("mt-4 flex flex-col px-2 lg:px-4", className)}>
|
||||
{items.map((item, index) => {
|
||||
if (item.divider) {
|
||||
return (
|
||||
<li key={index} className="w-full px-0.5 py-2">
|
||||
<hr className="h-px w-full border-none bg-border-secondary" />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.items?.length) {
|
||||
return (
|
||||
<details
|
||||
key={item.label}
|
||||
open={activeItem?.href === item.href}
|
||||
className="appearance-none py-0.5"
|
||||
onToggle={(e) => {
|
||||
setOpen(e.currentTarget.open);
|
||||
setCurrentItem(item);
|
||||
}}
|
||||
>
|
||||
<NavItemBase href={item.href} badge={item.badge} icon={item.icon} type="collapsible">
|
||||
{item.label}
|
||||
</NavItemBase>
|
||||
|
||||
<dd>
|
||||
<ul className="py-0.5">
|
||||
{item.items.map((childItem) => (
|
||||
<li key={childItem.label} className="py-0.5">
|
||||
<NavItemBase
|
||||
href={childItem.href}
|
||||
badge={childItem.badge}
|
||||
type="collapsible-child"
|
||||
current={activeUrl === childItem.href}
|
||||
>
|
||||
{childItem.label}
|
||||
</NavItemBase>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</dd>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={item.label} className="py-0.5">
|
||||
<NavItemBase
|
||||
type="link"
|
||||
badge={item.badge}
|
||||
icon={item.icon}
|
||||
href={item.href}
|
||||
current={currentItem?.href === item.href}
|
||||
open={open && currentItem?.href === item.href}
|
||||
>
|
||||
{item.label}
|
||||
</NavItemBase>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
23
src/components/application/app-navigation/config.ts
Normal file
23
src/components/application/app-navigation/config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
|
||||
export type NavItemType = {
|
||||
/** Label text for the nav item. */
|
||||
label: string;
|
||||
/** URL to navigate to when the nav item is clicked. */
|
||||
href?: string;
|
||||
/** Icon component to display. */
|
||||
icon?: FC<{ className?: string }>;
|
||||
/** Badge to display. */
|
||||
badge?: ReactNode;
|
||||
/** List of sub-items to display. */
|
||||
items?: { label: string; href: string; icon?: FC<{ className?: string }>; badge?: ReactNode }[];
|
||||
/** Whether this nav item is a divider. */
|
||||
divider?: boolean;
|
||||
};
|
||||
|
||||
export type NavItemDividerType = Omit<NavItemType, "icon" | "label" | "divider"> & {
|
||||
/** Label text for the divider. */
|
||||
label?: string;
|
||||
/** Whether this nav item is a divider. */
|
||||
divider: true;
|
||||
};
|
||||
202
src/components/application/app-navigation/header-navigation.tsx
Normal file
202
src/components/application/app-navigation/header-navigation.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { Bell01, LifeBuoy01, SearchLg, Settings01 } from "@untitledui/icons";
|
||||
import { Button as AriaButton, DialogTrigger, Popover } from "react-aria-components";
|
||||
import { Avatar } from "@/components/base/avatar/avatar";
|
||||
import { BadgeWithDot } from "@/components/base/badges/badges";
|
||||
import { Input } from "@/components/base/input/input";
|
||||
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
||||
import { cx } from "@/utils/cx";
|
||||
import { MobileNavigationHeader } from "./base-components/mobile-header";
|
||||
import { NavAccountCard, NavAccountMenu } from "./base-components/nav-account-card";
|
||||
import { NavItemBase } from "./base-components/nav-item";
|
||||
import { NavItemButton } from "./base-components/nav-item-button";
|
||||
import { NavList } from "./base-components/nav-list";
|
||||
|
||||
type NavItem = {
|
||||
/** Label text for the nav item. */
|
||||
label: string;
|
||||
/** URL to navigate to when the nav item is clicked. */
|
||||
href: string;
|
||||
/** Whether the nav item is currently active. */
|
||||
current?: boolean;
|
||||
/** Icon component to display. */
|
||||
icon?: FC<{ className?: string }>;
|
||||
/** Badge to display. */
|
||||
badge?: ReactNode;
|
||||
/** List of sub-items to display. */
|
||||
items?: NavItem[];
|
||||
};
|
||||
|
||||
interface HeaderNavigationBaseProps {
|
||||
/** URL of the currently active item. */
|
||||
activeUrl?: string;
|
||||
/** List of items to display. */
|
||||
items: NavItem[];
|
||||
/** List of sub-items to display. */
|
||||
subItems?: NavItem[];
|
||||
/** Content to display in the trailing position. */
|
||||
trailingContent?: ReactNode;
|
||||
/** Whether to show the avatar dropdown. */
|
||||
showAvatarDropdown?: boolean;
|
||||
/** Whether to hide the bottom border. */
|
||||
hideBorder?: boolean;
|
||||
}
|
||||
|
||||
export const HeaderNavigationBase = ({
|
||||
activeUrl,
|
||||
items,
|
||||
subItems,
|
||||
trailingContent,
|
||||
showAvatarDropdown = true,
|
||||
hideBorder = false,
|
||||
}: HeaderNavigationBaseProps) => {
|
||||
const activeSubNavItems = subItems || items.find((item) => item.current && item.items && item.items.length > 0)?.items;
|
||||
|
||||
const showSecondaryNav = activeSubNavItems && activeSubNavItems.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileNavigationHeader>
|
||||
<aside className="flex h-full max-w-full flex-col justify-between overflow-auto border-r border-secondary bg-primary pt-4 lg:pt-6">
|
||||
<div className="flex flex-col gap-5 px-4 lg:px-5">
|
||||
<UntitledLogo className="h-8" />
|
||||
<Input shortcut size="sm" aria-label="Search" placeholder="Search" icon={SearchLg} />
|
||||
</div>
|
||||
|
||||
<NavList items={items} />
|
||||
|
||||
<div className="mt-auto flex flex-col gap-4 px-2 py-4 lg:px-4 lg:py-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<NavItemBase type="link" href="#" icon={LifeBuoy01}>
|
||||
Support
|
||||
</NavItemBase>
|
||||
<NavItemBase
|
||||
type="link"
|
||||
href="#"
|
||||
icon={Settings01}
|
||||
badge={
|
||||
<BadgeWithDot color="success" type="modern" size="sm">
|
||||
Online
|
||||
</BadgeWithDot>
|
||||
}
|
||||
>
|
||||
Settings
|
||||
</NavItemBase>
|
||||
<NavItemBase type="link" href="https://www.untitledui.com/" icon={Settings01}>
|
||||
Open in browser
|
||||
</NavItemBase>
|
||||
</div>
|
||||
|
||||
<NavAccountCard />
|
||||
</div>
|
||||
</aside>
|
||||
</MobileNavigationHeader>
|
||||
|
||||
<header className="max-lg:hidden">
|
||||
<section
|
||||
className={cx(
|
||||
"flex h-16 w-full items-center justify-center bg-primary md:h-18",
|
||||
(!hideBorder || showSecondaryNav) && "border-b border-secondary",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full max-w-container justify-between pr-3 pl-4 md:px-8">
|
||||
<div className="flex flex-1 items-center gap-4">
|
||||
<a
|
||||
aria-label="Go to homepage"
|
||||
href="/"
|
||||
className="rounded-xs outline-focus-ring focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
>
|
||||
<UntitledLogo className="h-8" />
|
||||
</a>
|
||||
|
||||
<nav>
|
||||
<ul className="flex items-center gap-0.5">
|
||||
{items.map((item) => (
|
||||
<li key={item.label} className="py-0.5">
|
||||
<NavItemBase icon={item.icon} href={item.href} current={item.current} badge={item.badge} type="link">
|
||||
{item.label}
|
||||
</NavItemBase>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{trailingContent}
|
||||
|
||||
<div className="flex gap-0.5">
|
||||
<NavItemButton
|
||||
current={activeUrl === "/settings-01"}
|
||||
size="md"
|
||||
icon={Settings01}
|
||||
label="Settings"
|
||||
href="/settings-01"
|
||||
tooltipPlacement="bottom"
|
||||
/>
|
||||
<NavItemButton
|
||||
current={activeUrl === "/notifications-01"}
|
||||
size="md"
|
||||
icon={Bell01}
|
||||
label="Notifications"
|
||||
href="/notifications-01"
|
||||
tooltipPlacement="bottom"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showAvatarDropdown && (
|
||||
<DialogTrigger>
|
||||
<AriaButton
|
||||
className={({ isPressed, isFocused }) =>
|
||||
cx(
|
||||
"group relative inline-flex cursor-pointer",
|
||||
(isPressed || isFocused) && "rounded-full outline-2 outline-offset-2 outline-focus-ring",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Avatar alt="Olivia Rhye" src="https://www.untitledui.com/images/avatars/olivia-rhye?bg=%23E0E0E0" size="md" />
|
||||
</AriaButton>
|
||||
<Popover
|
||||
placement="bottom right"
|
||||
offset={8}
|
||||
className={({ isEntering, isExiting }) =>
|
||||
cx(
|
||||
"will-change-transform",
|
||||
isEntering &&
|
||||
"duration-300 ease-out animate-in fade-in placement-right:slide-in-from-left-2 placement-top:slide-in-from-bottom-2 placement-bottom:slide-in-from-top-2",
|
||||
isExiting &&
|
||||
"duration-150 ease-in animate-out fade-out placement-right:slide-out-to-left-2 placement-top:slide-out-to-bottom-2 placement-bottom:slide-out-to-top-2",
|
||||
)
|
||||
}
|
||||
>
|
||||
<NavAccountMenu />
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{showSecondaryNav && (
|
||||
<section className={cx("flex h-16 w-full items-center justify-center bg-primary", !hideBorder && "border-b border-secondary")}>
|
||||
<div className="flex w-full max-w-container items-center justify-between gap-8 px-8">
|
||||
<nav>
|
||||
<ul className="flex items-center gap-0.5">
|
||||
{activeSubNavItems.map((item) => (
|
||||
<li key={item.label} className="py-0.5">
|
||||
<NavItemBase icon={item.icon} href={item.href} current={item.current} badge={item.badge} type="link">
|
||||
{item.label}
|
||||
</NavItemBase>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<Input shortcut aria-label="Search" placeholder="Search" icon={SearchLg} size="sm" className="max-w-xs" />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export { MobileNavigationHeader } from "./base-components/mobile-header";
|
||||
export { NavAccountCard } from "./base-components/nav-account-card";
|
||||
export { NavItemButton } from "./base-components/nav-item-button";
|
||||
export { NavItemBase } from "./base-components/nav-item";
|
||||
export { NavList } from "./base-components/nav-list";
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { SearchLg } from "@untitledui/icons";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Input } from "@/components/base/input/input";
|
||||
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
||||
import { cx } from "@/utils/cx";
|
||||
import { MobileNavigationHeader } from "../base-components/mobile-header";
|
||||
import { NavAccountCard } from "../base-components/nav-account-card";
|
||||
import { NavItemBase } from "../base-components/nav-item";
|
||||
import { NavList } from "../base-components/nav-list";
|
||||
import type { NavItemType } from "../config";
|
||||
|
||||
interface SidebarNavigationDualTierProps {
|
||||
/** URL of the currently active item. */
|
||||
activeUrl?: string;
|
||||
/** Feature card to display. */
|
||||
featureCard?: ReactNode;
|
||||
/** List of items to display. */
|
||||
items: NavItemType[];
|
||||
/** List of footer items to display. */
|
||||
footerItems?: NavItemType[];
|
||||
/** Whether to hide the right side border. */
|
||||
hideBorder?: boolean;
|
||||
}
|
||||
|
||||
export const SidebarNavigationDualTier = ({ activeUrl, hideBorder, items, footerItems = [], featureCard }: SidebarNavigationDualTierProps) => {
|
||||
const activeItem = [...items, ...footerItems].find((item) => item.href === activeUrl || item.items?.some((subItem) => subItem.href === activeUrl));
|
||||
const [currentItem, setCurrentItem] = useState(activeItem || items[1]);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
const isSecondarySidebarVisible = isHovering && Boolean(currentItem.items?.length);
|
||||
|
||||
const MAIN_SIDEBAR_WIDTH = 296;
|
||||
const SECONDARY_SIDEBAR_WIDTH = 256;
|
||||
|
||||
const mainSidebar = (
|
||||
<aside className="group flex h-full max-h-full max-w-full overflow-y-auto bg-primary">
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--width": `${MAIN_SIDEBAR_WIDTH}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cx(
|
||||
"relative flex w-full flex-col border-r border-secondary pt-4 transition duration-300 lg:w-(--width) lg:pt-6",
|
||||
hideBorder && !isSecondarySidebarVisible && "border-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-5 px-4 lg:px-5">
|
||||
<UntitledLogo className="h-8" />
|
||||
<Input shortcut size="sm" aria-label="Search" placeholder="Search" icon={SearchLg} />
|
||||
</div>
|
||||
|
||||
<NavList activeUrl={activeUrl} items={items} className="lg:hidden" />
|
||||
|
||||
<ul className="mt-4 hidden flex-col px-4 lg:flex">
|
||||
{items.map((item) => (
|
||||
<li key={item.label + item.href} className="py-0.5">
|
||||
<NavItemBase
|
||||
current={currentItem.href === item.href}
|
||||
href={item.href}
|
||||
badge={item.badge}
|
||||
icon={item.icon}
|
||||
type="link"
|
||||
onClick={() => setCurrentItem(item)}
|
||||
>
|
||||
{item.label}
|
||||
</NavItemBase>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-auto flex flex-col gap-4 px-2 py-4 lg:px-4 lg:py-6">
|
||||
{footerItems.length > 0 && (
|
||||
<ul className="flex flex-col">
|
||||
{footerItems.map((item) => (
|
||||
<li key={item.label + item.href} className="py-0.5">
|
||||
<NavItemBase
|
||||
current={currentItem.href === item.href}
|
||||
href={item.href}
|
||||
badge={item.badge}
|
||||
icon={item.icon}
|
||||
type="link"
|
||||
onClick={() => setCurrentItem(item)}
|
||||
>
|
||||
{item.label}
|
||||
</NavItemBase>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{featureCard}
|
||||
|
||||
<NavAccountCard />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
const secondarySidebar = (
|
||||
<AnimatePresence initial={false}>
|
||||
{isSecondarySidebarVisible && (
|
||||
<motion.div
|
||||
initial={{ width: 0, borderColor: "var(--color-border-secondary)" }}
|
||||
animate={{ width: SECONDARY_SIDEBAR_WIDTH, borderColor: "var(--color-border-secondary)" }}
|
||||
exit={{ width: 0, borderColor: "rgba(0,0,0,0)", transition: { borderColor: { type: "tween", delay: 0.05 } } }}
|
||||
transition={{ type: "spring", damping: 26, stiffness: 220, bounce: 0 }}
|
||||
className={cx("relative h-full overflow-x-hidden overflow-y-auto bg-primary", !hideBorder && "box-content border-r-[1.5px]")}
|
||||
>
|
||||
<ul style={{ width: SECONDARY_SIDEBAR_WIDTH }} className="flex h-full flex-col p-4 py-6">
|
||||
{currentItem.items?.map((item) => (
|
||||
<li key={item.label + item.href} className="py-0.5">
|
||||
<NavItemBase current={activeUrl === item.href} href={item.href} icon={item.icon} badge={item.badge} type="link">
|
||||
{item.label}
|
||||
</NavItemBase>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile header navigation */}
|
||||
<MobileNavigationHeader>{mainSidebar}</MobileNavigationHeader>
|
||||
|
||||
{/* Desktop sidebar navigation */}
|
||||
<div
|
||||
className="z-50 hidden lg:fixed lg:inset-y-0 lg:left-0 lg:flex"
|
||||
onPointerEnter={() => setIsHovering(true)}
|
||||
onPointerLeave={() => setIsHovering(false)}
|
||||
>
|
||||
{mainSidebar}
|
||||
{secondarySidebar}
|
||||
</div>
|
||||
|
||||
{/* Placeholder to take up physical space because the real sidebar has `fixed` position. */}
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: MAIN_SIDEBAR_WIDTH,
|
||||
}}
|
||||
className="invisible hidden lg:sticky lg:top-0 lg:bottom-0 lg:left-0 lg:block"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { SearchLg } from "@untitledui/icons";
|
||||
import { Input } from "@/components/base/input/input";
|
||||
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
||||
import { MobileNavigationHeader } from "../base-components/mobile-header";
|
||||
import { NavAccountCard } from "../base-components/nav-account-card";
|
||||
import { NavList } from "../base-components/nav-list";
|
||||
import type { NavItemDividerType, NavItemType } from "../config";
|
||||
|
||||
interface SidebarNavigationSectionDividersProps {
|
||||
/** URL of the currently active item. */
|
||||
activeUrl?: string;
|
||||
/** List of items to display. */
|
||||
items: (NavItemType | NavItemDividerType)[];
|
||||
}
|
||||
|
||||
export const SidebarNavigationSectionDividers = ({ activeUrl, items }: SidebarNavigationSectionDividersProps) => {
|
||||
const MAIN_SIDEBAR_WIDTH = 292;
|
||||
|
||||
const content = (
|
||||
<aside
|
||||
style={
|
||||
{
|
||||
"--width": `${MAIN_SIDEBAR_WIDTH}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className="flex h-full w-full max-w-full flex-col justify-between overflow-auto border-secondary bg-primary pt-4 shadow-xs md:border-r lg:w-(--width) lg:rounded-xl lg:border lg:pt-5"
|
||||
>
|
||||
<div className="flex flex-col gap-5 px-4 lg:px-5">
|
||||
<UntitledLogo className="h-8" />
|
||||
<Input shortcut size="sm" aria-label="Search" placeholder="Search" icon={SearchLg} />
|
||||
</div>
|
||||
|
||||
<NavList activeUrl={activeUrl} items={items} className="mt-5" />
|
||||
|
||||
<div className="mt-auto flex flex-col gap-5 px-2 py-4 lg:gap-6 lg:px-4 lg:py-4">
|
||||
<NavAccountCard />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile header navigation */}
|
||||
<MobileNavigationHeader>{content}</MobileNavigationHeader>
|
||||
|
||||
{/* Desktop sidebar navigation */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:flex lg:py-1 lg:pl-1">{content}</div>
|
||||
|
||||
{/* Placeholder to take up physical space because the real sidebar has `fixed` position. */}
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: MAIN_SIDEBAR_WIDTH + 4, // Add 4px to account for the padding in the sidebar wrapper
|
||||
}}
|
||||
className="invisible hidden lg:sticky lg:top-0 lg:bottom-0 lg:left-0 lg:block"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
||||
import { MobileNavigationHeader } from "../base-components/mobile-header";
|
||||
import { NavAccountCard } from "../base-components/nav-account-card";
|
||||
import { NavItemBase } from "../base-components/nav-item";
|
||||
import type { NavItemType } from "../config";
|
||||
|
||||
interface SidebarNavigationSectionsSubheadingsProps {
|
||||
/** URL of the currently active item. */
|
||||
activeUrl?: string;
|
||||
/** List of items to display. */
|
||||
items: Array<{ label: string; items: NavItemType[] }>;
|
||||
}
|
||||
|
||||
export const SidebarNavigationSectionsSubheadings = ({ activeUrl = "/", items }: SidebarNavigationSectionsSubheadingsProps) => {
|
||||
const MAIN_SIDEBAR_WIDTH = 292;
|
||||
|
||||
const content = (
|
||||
<aside
|
||||
style={
|
||||
{
|
||||
"--width": `${MAIN_SIDEBAR_WIDTH}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className="flex h-full w-full max-w-full flex-col justify-between overflow-auto border-secondary bg-primary pt-4 shadow-xs md:border-r lg:w-(--width) lg:rounded-xl lg:border lg:pt-5"
|
||||
>
|
||||
<div className="flex flex-col gap-5 px-4 lg:px-5">
|
||||
<UntitledLogo className="h-8" />
|
||||
</div>
|
||||
|
||||
<ul className="mt-8">
|
||||
{items.map((group) => (
|
||||
<li key={group.label}>
|
||||
<div className="px-5 pb-1">
|
||||
<p className="text-xs font-bold text-quaternary uppercase">{group.label}</p>
|
||||
</div>
|
||||
<ul className="px-4 pb-5">
|
||||
{group.items.map((item) => (
|
||||
<li key={item.label} className="py-0.5">
|
||||
<NavItemBase icon={item.icon} href={item.href} badge={item.badge} type="link" current={item.href === activeUrl}>
|
||||
{item.label}
|
||||
</NavItemBase>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-5 px-2 py-4 lg:gap-6 lg:px-4 lg:py-4">
|
||||
<NavAccountCard />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile header navigation */}
|
||||
<MobileNavigationHeader>{content}</MobileNavigationHeader>
|
||||
|
||||
{/* Desktop sidebar navigation */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:flex lg:py-1 lg:pl-1">{content}</div>
|
||||
|
||||
{/* Placeholder to take up physical space because the real sidebar has `fixed` position. */}
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: MAIN_SIDEBAR_WIDTH + 4,
|
||||
}}
|
||||
className="invisible hidden lg:sticky lg:top-0 lg:bottom-0 lg:left-0 lg:block"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { SearchLg } from "@untitledui/icons";
|
||||
import { Input } from "@/components/base/input/input";
|
||||
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
||||
import { cx } from "@/utils/cx";
|
||||
import { MobileNavigationHeader } from "../base-components/mobile-header";
|
||||
import { NavAccountCard } from "../base-components/nav-account-card";
|
||||
import { NavItemBase } from "../base-components/nav-item";
|
||||
import { NavList } from "../base-components/nav-list";
|
||||
import type { NavItemType } from "../config";
|
||||
|
||||
interface SidebarNavigationProps {
|
||||
/** URL of the currently active item. */
|
||||
activeUrl?: string;
|
||||
/** List of items to display. */
|
||||
items: NavItemType[];
|
||||
/** List of footer items to display. */
|
||||
footerItems?: NavItemType[];
|
||||
/** Feature card to display. */
|
||||
featureCard?: ReactNode;
|
||||
/** Whether to show the account card. */
|
||||
showAccountCard?: boolean;
|
||||
/** Whether to hide the right side border. */
|
||||
hideBorder?: boolean;
|
||||
/** Additional CSS classes to apply to the sidebar. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SidebarNavigationSimple = ({
|
||||
activeUrl,
|
||||
items,
|
||||
footerItems = [],
|
||||
featureCard,
|
||||
showAccountCard = true,
|
||||
hideBorder = false,
|
||||
className,
|
||||
}: SidebarNavigationProps) => {
|
||||
const MAIN_SIDEBAR_WIDTH = 296;
|
||||
|
||||
const content = (
|
||||
<aside
|
||||
style={
|
||||
{
|
||||
"--width": `${MAIN_SIDEBAR_WIDTH}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cx(
|
||||
"flex h-full w-full max-w-full flex-col justify-between overflow-auto bg-primary pt-4 lg:w-(--width) lg:pt-6",
|
||||
!hideBorder && "border-secondary md:border-r",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-5 px-4 lg:px-5">
|
||||
<UntitledLogo className="h-8" />
|
||||
<Input shortcut size="sm" aria-label="Search" placeholder="Search" icon={SearchLg} />
|
||||
</div>
|
||||
|
||||
<NavList activeUrl={activeUrl} items={items} />
|
||||
|
||||
<div className="mt-auto flex flex-col gap-4 px-2 py-4 lg:px-4 lg:py-6">
|
||||
{footerItems.length > 0 && (
|
||||
<ul className="flex flex-col">
|
||||
{footerItems.map((item) => (
|
||||
<li key={item.label} className="py-0.5">
|
||||
<NavItemBase badge={item.badge} icon={item.icon} href={item.href} type="link" current={item.href === activeUrl}>
|
||||
{item.label}
|
||||
</NavItemBase>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{featureCard}
|
||||
|
||||
{showAccountCard && <NavAccountCard />}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile header navigation */}
|
||||
<MobileNavigationHeader>{content}</MobileNavigationHeader>
|
||||
|
||||
{/* Desktop sidebar navigation */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:flex">{content}</div>
|
||||
|
||||
{/* Placeholder to take up physical space because the real sidebar has `fixed` position. */}
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: MAIN_SIDEBAR_WIDTH,
|
||||
}}
|
||||
className="invisible hidden lg:sticky lg:top-0 lg:bottom-0 lg:left-0 lg:block"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { LifeBuoy01, LogOut01, Settings01 } from "@untitledui/icons";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Button as AriaButton, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
|
||||
import { Avatar } from "@/components/base/avatar/avatar";
|
||||
import { AvatarLabelGroup } from "@/components/base/avatar/avatar-label-group";
|
||||
import { Button } from "@/components/base/buttons/button";
|
||||
import { ButtonUtility } from "@/components/base/buttons/button-utility";
|
||||
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
|
||||
import { UntitledLogoMinimal } from "@/components/foundations/logo/untitledui-logo-minimal";
|
||||
import { cx } from "@/utils/cx";
|
||||
import { MobileNavigationHeader } from "../base-components/mobile-header";
|
||||
import { NavAccountMenu } from "../base-components/nav-account-card";
|
||||
import { NavItemBase } from "../base-components/nav-item";
|
||||
import { NavItemButton } from "../base-components/nav-item-button";
|
||||
import { NavList } from "../base-components/nav-list";
|
||||
import type { NavItemType } from "../config";
|
||||
|
||||
interface SidebarNavigationSlimProps {
|
||||
/** URL of the currently active item. */
|
||||
activeUrl?: string;
|
||||
/** List of items to display. */
|
||||
items: (NavItemType & { icon: FC<{ className?: string }> })[];
|
||||
/** List of footer items to display. */
|
||||
footerItems?: (NavItemType & { icon: FC<{ className?: string }> })[];
|
||||
/** Whether to hide the border. */
|
||||
hideBorder?: boolean;
|
||||
/** Whether to hide the right side border. */
|
||||
hideRightBorder?: boolean;
|
||||
}
|
||||
|
||||
export const SidebarNavigationSlim = ({ activeUrl, items, footerItems = [], hideBorder, hideRightBorder }: SidebarNavigationSlimProps) => {
|
||||
const activeItem = [...items, ...footerItems].find((item) => item.href === activeUrl || item.items?.some((subItem) => subItem.href === activeUrl));
|
||||
const [currentItem, setCurrentItem] = useState(activeItem || items[1]);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
const isSecondarySidebarVisible = isHovering && Boolean(currentItem.items?.length);
|
||||
|
||||
const MAIN_SIDEBAR_WIDTH = 68;
|
||||
const SECONDARY_SIDEBAR_WIDTH = 268;
|
||||
|
||||
const mainSidebar = (
|
||||
<aside
|
||||
style={{
|
||||
width: MAIN_SIDEBAR_WIDTH,
|
||||
}}
|
||||
className={cx(
|
||||
"group flex h-full max-h-full max-w-full overflow-y-auto py-1 pl-1 transition duration-100 ease-linear",
|
||||
isSecondarySidebarVisible && "bg-primary",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cx(
|
||||
"flex w-auto flex-col justify-between rounded-xl bg-primary pt-5 ring-1 ring-secondary transition duration-300 ring-inset",
|
||||
hideBorder && !isSecondarySidebarVisible && "ring-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-center px-3">
|
||||
<UntitledLogoMinimal className="size-8" />
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 flex flex-col gap-0.5 px-3">
|
||||
{items.map((item) => (
|
||||
<li key={item.label}>
|
||||
<NavItemButton
|
||||
size="md"
|
||||
current={currentItem.href === item.href}
|
||||
href={item.href}
|
||||
label={item.label || ""}
|
||||
icon={item.icon}
|
||||
onClick={() => setCurrentItem(item)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-auto flex flex-col gap-4 px-3 py-5">
|
||||
{footerItems.length > 0 && (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{footerItems.map((item) => (
|
||||
<li key={item.label}>
|
||||
<NavItemButton
|
||||
size="md"
|
||||
current={currentItem.href === item.href}
|
||||
label={item.label || ""}
|
||||
href={item.href}
|
||||
icon={item.icon}
|
||||
onClick={() => setCurrentItem(item)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<AriaDialogTrigger>
|
||||
<AriaButton
|
||||
className={({ isPressed, isFocused }) =>
|
||||
cx("group relative inline-flex rounded-full", (isPressed || isFocused) && "outline-2 outline-offset-2 outline-focus-ring")
|
||||
}
|
||||
>
|
||||
<Avatar status="online" src="https://www.untitledui.com/images/avatars/olivia-rhye?fm=webp&q=80" size="md" alt="Olivia Rhye" />
|
||||
</AriaButton>
|
||||
<AriaPopover
|
||||
placement="right bottom"
|
||||
offset={8}
|
||||
crossOffset={6}
|
||||
className={({ isEntering, isExiting }) =>
|
||||
cx(
|
||||
"will-change-transform",
|
||||
isEntering &&
|
||||
"duration-300 ease-out animate-in fade-in placement-right:slide-in-from-left-2 placement-top:slide-in-from-bottom-2 placement-bottom:slide-in-from-top-2",
|
||||
isExiting &&
|
||||
"duration-150 ease-in animate-out fade-out placement-right:slide-out-to-left-2 placement-top:slide-out-to-bottom-2 placement-bottom:slide-out-to-top-2",
|
||||
)
|
||||
}
|
||||
>
|
||||
<NavAccountMenu />
|
||||
</AriaPopover>
|
||||
</AriaDialogTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
const secondarySidebar = (
|
||||
<AnimatePresence initial={false}>
|
||||
{isSecondarySidebarVisible && (
|
||||
<motion.div
|
||||
initial={{ width: 0, borderColor: "var(--color-border-secondary)" }}
|
||||
animate={{ width: SECONDARY_SIDEBAR_WIDTH, borderColor: "var(--color-border-secondary)" }}
|
||||
exit={{ width: 0, borderColor: "rgba(0,0,0,0)", transition: { borderColor: { type: "tween", delay: 0.05 } } }}
|
||||
transition={{ type: "spring", damping: 26, stiffness: 220, bounce: 0 }}
|
||||
className={cx(
|
||||
"relative h-full overflow-x-hidden overflow-y-auto bg-primary",
|
||||
!(hideBorder || hideRightBorder) && "box-content border-r-[1.5px]",
|
||||
)}
|
||||
>
|
||||
<div style={{ width: SECONDARY_SIDEBAR_WIDTH }} className="flex h-full flex-col px-4 pt-6">
|
||||
<h3 className="text-sm font-semibold text-brand-secondary">{currentItem.label}</h3>
|
||||
<ul className="py-2">
|
||||
{currentItem.items?.map((item) => (
|
||||
<li key={item.label} className="py-0.5">
|
||||
<NavItemBase current={activeUrl === item.href} href={item.href} icon={item.icon} badge={item.badge} type="link">
|
||||
{item.label}
|
||||
</NavItemBase>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="sticky bottom-0 mt-auto flex justify-between border-t border-secondary bg-primary px-2 py-5">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-primary">Olivia Rhye</p>
|
||||
<p className="text-sm text-tertiary">olivia@untitledui.com</p>
|
||||
</div>
|
||||
<div className="absolute top-2.5 right-0">
|
||||
<ButtonUtility size="sm" color="tertiary" tooltip="Log out" icon={LogOut01} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop sidebar navigation */}
|
||||
<div
|
||||
className="z-50 hidden lg:fixed lg:inset-y-0 lg:left-0 lg:flex"
|
||||
onPointerEnter={() => setIsHovering(true)}
|
||||
onPointerLeave={() => setIsHovering(false)}
|
||||
>
|
||||
{mainSidebar}
|
||||
{secondarySidebar}
|
||||
</div>
|
||||
|
||||
{/* Placeholder to take up physical space because the real sidebar has `fixed` position. */}
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: MAIN_SIDEBAR_WIDTH,
|
||||
}}
|
||||
className="invisible hidden lg:sticky lg:top-0 lg:bottom-0 lg:left-0 lg:block"
|
||||
/>
|
||||
|
||||
{/* Mobile header navigation */}
|
||||
<MobileNavigationHeader>
|
||||
<aside className="group flex h-full max-h-full w-full max-w-full flex-col justify-between overflow-y-auto bg-primary pt-4">
|
||||
<div className="px-4">
|
||||
<UntitledLogo className="h-8" />
|
||||
</div>
|
||||
|
||||
<NavList items={items} />
|
||||
|
||||
<div className="mt-auto flex flex-col gap-5 px-2 py-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<NavItemBase current={activeUrl === "/support"} type="link" href="/support" icon={LifeBuoy01}>
|
||||
Support
|
||||
</NavItemBase>
|
||||
<NavItemBase current={activeUrl === "/settings"} type="link" href="/settings" icon={Settings01}>
|
||||
Settings
|
||||
</NavItemBase>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-3 border-t border-secondary pt-6 pr-8 pl-2">
|
||||
<AvatarLabelGroup
|
||||
status="online"
|
||||
size="md"
|
||||
src="https://www.untitledui.com/images/avatars/olivia-rhye?fm=webp&q=80"
|
||||
title="Olivia Rhye"
|
||||
subtitle="olivia@untitledui.com"
|
||||
/>
|
||||
|
||||
<div className="absolute top-1/2 right-0 -translate-y-1/2">
|
||||
<Button
|
||||
size="sm"
|
||||
color="tertiary"
|
||||
iconLeading={<LogOut01 className="size-5 text-fg-quaternary transition-inherit-all group-hover:text-fg-quaternary_hover" />}
|
||||
className="p-1.5!"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</MobileNavigationHeader>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user