chore: initial Untitled UI Vite scaffold with FontAwesome Pro

This commit is contained in:
2026-03-16 14:23:23 +05:30
commit 3a338b33dd
163 changed files with 27081 additions and 0 deletions

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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;
};

View 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>
</>
);
};

View File

@@ -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";

View File

@@ -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"
/>
</>
);
};

View File

@@ -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"
/>
</>
);
};

View File

@@ -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"
/>
</>
);
};

View File

@@ -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"
/>
</>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -0,0 +1,99 @@
import type { HTMLAttributes, PropsWithChildren } from "react";
import { Fragment, useContext, useState } from "react";
import { type CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
import { ChevronLeft, ChevronRight } from "@untitledui/icons";
import type { CalendarProps as AriaCalendarProps, DateValue } from "react-aria-components";
import {
Calendar as AriaCalendar,
CalendarContext as AriaCalendarContext,
CalendarGrid as AriaCalendarGrid,
CalendarGridBody as AriaCalendarGridBody,
CalendarGridHeader as AriaCalendarGridHeader,
CalendarHeaderCell as AriaCalendarHeaderCell,
CalendarStateContext as AriaCalendarStateContext,
Heading as AriaHeading,
useSlottedContext,
} from "react-aria-components";
import { Button } from "@/components/base/buttons/button";
import { cx } from "@/utils/cx";
import { CalendarCell } from "./cell";
import { DateInput } from "./date-input";
export const CalendarContextProvider = ({ children }: PropsWithChildren) => {
const [value, onChange] = useState<DateValue | null>(null);
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();
return <AriaCalendarContext.Provider value={{ value, onChange, focusedValue, onFocusChange }}>{children}</AriaCalendarContext.Provider>;
};
const PresetButton = ({ value, children, ...props }: HTMLAttributes<HTMLButtonElement> & { value: CalendarDate }) => {
const context = useContext(AriaCalendarStateContext);
if (!context) {
throw new Error("Preset must be used within a Calendar component");
}
const handleClick = () => {
context.setValue(value);
context.setFocusedDate(value);
};
return (
<Button
{...props}
// It's important to give `null` explicitly to the `slot` prop
// otherwise the button will throw an error due to not using one of
// the required slots inside the Calendar component.
// Passing `null` will tell the button to not use a slot context.
slot={null}
size="md"
color="secondary"
onClick={handleClick}
>
{children}
</Button>
);
};
interface CalendarProps extends AriaCalendarProps<DateValue> {
/** The dates to highlight. */
highlightedDates?: DateValue[];
}
export const Calendar = ({ highlightedDates, className, ...props }: CalendarProps) => {
const context = useSlottedContext(AriaCalendarContext)!;
const ContextWrapper = context ? Fragment : CalendarContextProvider;
return (
<ContextWrapper>
<AriaCalendar {...props} className={(state) => cx("flex flex-col gap-3", typeof className === "function" ? className(state) : className)}>
<header className="flex items-center justify-between">
<Button slot="previous" iconLeading={ChevronLeft} size="sm" color="tertiary" className="size-8" />
<AriaHeading className="text-sm font-semibold text-fg-secondary" />
<Button slot="next" iconLeading={ChevronRight} size="sm" color="tertiary" className="size-8" />
</header>
<div className="flex gap-3">
<DateInput className="flex-1" />
<PresetButton value={today(getLocalTimeZone())}>Today</PresetButton>
</div>
<AriaCalendarGrid weekdayStyle="short" className="w-max">
<AriaCalendarGridHeader className="border-b-4 border-transparent">
{(day) => (
<AriaCalendarHeaderCell className="p-0">
<div className="flex size-10 items-center justify-center text-sm font-medium text-secondary">{day.slice(0, 2)}</div>
</AriaCalendarHeaderCell>
)}
</AriaCalendarGridHeader>
<AriaCalendarGridBody className="[&_td]:p-0 [&_tr]:border-b-4 [&_tr]:border-transparent [&_tr:last-of-type]:border-none">
{(date) => (
<CalendarCell date={date} isHighlighted={highlightedDates?.some((highlightedDate) => date.compare(highlightedDate) === 0)} />
)}
</AriaCalendarGridBody>
</AriaCalendarGrid>
</AriaCalendar>
</ContextWrapper>
);
};

View File

@@ -0,0 +1,104 @@
import { getDayOfWeek, getLocalTimeZone, isToday } from "@internationalized/date";
import type { CalendarCellProps as AriaCalendarCellProps } from "react-aria-components";
import { CalendarCell as AriaCalendarCell, RangeCalendarContext, useLocale, useSlottedContext } from "react-aria-components";
import { cx } from "@/utils/cx";
interface CalendarCellProps extends AriaCalendarCellProps {
/** Whether the calendar is a range calendar. */
isRangeCalendar?: boolean;
/** Whether the cell is highlighted. */
isHighlighted?: boolean;
}
export const CalendarCell = ({ date, isHighlighted, ...props }: CalendarCellProps) => {
const { locale } = useLocale();
const dayOfWeek = getDayOfWeek(date, locale);
const rangeCalendarContext = useSlottedContext(RangeCalendarContext);
const isRangeCalendar = !!rangeCalendarContext;
const start = rangeCalendarContext?.value?.start;
const end = rangeCalendarContext?.value?.end;
const isAfterStart = start ? date.compare(start) > 0 : true;
const isBeforeEnd = end ? date.compare(end) < 0 : true;
const isAfterOrOnStart = start && date.compare(start) >= 0;
const isBeforeOrOnEnd = end && date.compare(end) <= 0;
const isInRange = isAfterOrOnStart && isBeforeOrOnEnd;
const lastDayOfMonth = new Date(date.year, date.month, 0).getDate();
const isLastDayOfMonth = date.day === lastDayOfMonth;
const isFirstDayOfMonth = date.day === 1;
const isTodayDate = isToday(date, getLocalTimeZone());
return (
<AriaCalendarCell
{...props}
date={date}
className={({ isDisabled, isFocusVisible, isSelectionStart, isSelectionEnd, isSelected, isOutsideMonth }) => {
const isRoundedLeft = isSelectionStart || dayOfWeek === 0;
const isRoundedRight = isSelectionEnd || dayOfWeek === 6;
return cx(
"relative size-10 focus:outline-none",
isRoundedLeft && "rounded-l-full",
isRoundedRight && "rounded-r-full",
isInRange && isDisabled && "bg-active",
isSelected && isRangeCalendar && "bg-active",
isDisabled ? "pointer-events-none" : "cursor-pointer",
isFocusVisible ? "z-10" : "z-0",
isRangeCalendar && isOutsideMonth && "hidden",
// Show gradient on last day of month if it's within the selected range.
isLastDayOfMonth &&
isSelected &&
isBeforeEnd &&
isRangeCalendar &&
"after:absolute after:inset-0 after:translate-x-full after:bg-gradient-to-l after:from-transparent after:to-bg-active in-[[role=gridcell]:last-child]:after:hidden",
// Show gradient on first day of month if it's within the selected range.
isFirstDayOfMonth &&
isSelected &&
isAfterStart &&
isRangeCalendar &&
"after:absolute after:inset-0 after:-translate-x-full after:bg-gradient-to-r after:from-transparent after:to-bg-active in-[[role=gridcell]:first-child]:after:hidden",
);
}}
>
{({ isDisabled, isFocusVisible, isSelectionStart, isSelectionEnd, isSelected, formattedDate }) => {
const markedAsSelected = isSelectionStart || isSelectionEnd || (isSelected && !isDisabled && !isRangeCalendar);
return (
<div
className={cx(
"relative flex size-full items-center justify-center rounded-full text-sm",
// Disabled state.
isDisabled ? "text-disabled" : "text-secondary hover:text-secondary_hover",
// Focus ring, visible while the cell has keyboard focus.
isFocusVisible ? "outline-2 outline-offset-2 outline-focus-ring" : "",
// Hover state for cells in the middle of the range.
isSelected && !isDisabled && isRangeCalendar ? "font-medium" : "",
markedAsSelected && "bg-brand-solid font-medium text-white hover:bg-brand-solid_hover hover:text-white",
// Hover state for non-selected cells.
!isSelected && !isDisabled ? "hover:bg-primary_hover hover:font-medium!" : "",
!isSelected && isTodayDate ? "bg-active font-medium hover:bg-secondary_hover" : "",
)}
>
{formattedDate}
{(isHighlighted || isTodayDate) && (
<div
className={cx(
"absolute bottom-1 left-1/2 size-1.25 -translate-x-1/2 rounded-full",
isDisabled ? "bg-fg-disabled" : markedAsSelected ? "bg-fg-white" : "bg-fg-brand-primary",
)}
/>
)}
</div>
);
}}
</AriaCalendarCell>
);
};

View File

@@ -0,0 +1,30 @@
import type { DateInputProps as AriaDateInputProps } from "react-aria-components";
import { DateInput as AriaDateInput, DateSegment as AriaDateSegment } from "react-aria-components";
import { cx } from "@/utils/cx";
interface DateInputProps extends Omit<AriaDateInputProps, "children"> {}
export const DateInput = (props: DateInputProps) => {
return (
<AriaDateInput
{...props}
className={cx(
"flex rounded-lg bg-primary px-2.5 py-2 text-md shadow-xs ring-1 ring-primary ring-inset focus-within:ring-2 focus-within:ring-brand",
typeof props.className === "string" && props.className,
)}
>
{(segment) => (
<AriaDateSegment
segment={segment}
className={cx(
"rounded px-0.5 text-primary tabular-nums caret-transparent focus:bg-brand-solid focus:font-medium focus:text-white focus:outline-hidden",
// The placeholder segment.
segment.isPlaceholder && "text-placeholder uppercase",
// The separator "/" segment.
segment.type === "literal" && "text-fg-quaternary",
)}
/>
)}
</AriaDateInput>
);
};

View File

@@ -0,0 +1,84 @@
import { getLocalTimeZone, today } from "@internationalized/date";
import { useControlledState } from "@react-stately/utils";
import { Calendar as CalendarIcon } from "@untitledui/icons";
import { useDateFormatter } from "react-aria";
import type { DatePickerProps as AriaDatePickerProps, DateValue } from "react-aria-components";
import { DatePicker as AriaDatePicker, Dialog as AriaDialog, Group as AriaGroup, Popover as AriaPopover } from "react-aria-components";
import { Button } from "@/components/base/buttons/button";
import { cx } from "@/utils/cx";
import { Calendar } from "./calendar";
const highlightedDates = [today(getLocalTimeZone())];
interface DatePickerProps extends AriaDatePickerProps<DateValue> {
/** The function to call when the apply button is clicked. */
onApply?: () => void;
/** The function to call when the cancel button is clicked. */
onCancel?: () => void;
}
export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, onCancel, ...props }: DatePickerProps) => {
const formatter = useDateFormatter({
month: "short",
day: "numeric",
year: "numeric",
});
const [value, setValue] = useControlledState(valueProp, defaultValue || null, onChange);
const formattedDate = value ? formatter.format(value.toDate(getLocalTimeZone())) : "Select date";
return (
<AriaDatePicker shouldCloseOnSelect={false} {...props} value={value} onChange={setValue}>
<AriaGroup>
<Button size="md" color="secondary" iconLeading={CalendarIcon}>
{formattedDate}
</Button>
</AriaGroup>
<AriaPopover
offset={8}
placement="bottom right"
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",
)
}
>
<AriaDialog className="rounded-2xl bg-primary shadow-xl ring ring-secondary_alt">
{({ close }) => (
<>
<div className="flex px-6 py-5">
<Calendar highlightedDates={highlightedDates} />
</div>
<div className="grid grid-cols-2 gap-3 border-t border-secondary p-4">
<Button
size="md"
color="secondary"
onClick={() => {
onCancel?.();
close();
}}
>
Cancel
</Button>
<Button
size="md"
color="primary"
onClick={() => {
onApply?.();
close();
}}
>
Apply
</Button>
</div>
</>
)}
</AriaDialog>
</AriaPopover>
</AriaDatePicker>
);
};

View File

@@ -0,0 +1,161 @@
import { useMemo, useState } from "react";
import { endOfMonth, endOfWeek, getLocalTimeZone, startOfMonth, startOfWeek, today } from "@internationalized/date";
import { useControlledState } from "@react-stately/utils";
import { Calendar as CalendarIcon } from "@untitledui/icons";
import { useDateFormatter } from "react-aria";
import type { DateRangePickerProps as AriaDateRangePickerProps, DateValue } from "react-aria-components";
import { DateRangePicker as AriaDateRangePicker, Dialog as AriaDialog, Group as AriaGroup, Popover as AriaPopover, useLocale } from "react-aria-components";
import { Button } from "@/components/base/buttons/button";
import { cx } from "@/utils/cx";
import { DateInput } from "./date-input";
import { RangeCalendar } from "./range-calendar";
import { RangePresetButton } from "./range-preset";
const now = today(getLocalTimeZone());
const highlightedDates = [today(getLocalTimeZone())];
interface DateRangePickerProps extends AriaDateRangePickerProps<DateValue> {
/** The function to call when the apply button is clicked. */
onApply?: () => void;
/** The function to call when the cancel button is clicked. */
onCancel?: () => void;
}
export const DateRangePicker = ({ value: valueProp, defaultValue, onChange, onApply, onCancel, ...props }: DateRangePickerProps) => {
const { locale } = useLocale();
const formatter = useDateFormatter({
month: "short",
day: "numeric",
year: "numeric",
});
const [value, setValue] = useControlledState(valueProp, defaultValue || null, onChange);
const [focusedValue, setFocusedValue] = useState<DateValue | null>(null);
const formattedStartDate = value?.start ? formatter.format(value.start.toDate(getLocalTimeZone())) : "Select date";
const formattedEndDate = value?.end ? formatter.format(value.end.toDate(getLocalTimeZone())) : "Select date";
const presets = useMemo(
() => ({
today: { label: "Today", value: { start: now, end: now } },
yesterday: { label: "Yesterday", value: { start: now.subtract({ days: 1 }), end: now.subtract({ days: 1 }) } },
thisWeek: { label: "This week", value: { start: startOfWeek(now, locale), end: endOfWeek(now, locale) } },
lastWeek: {
label: "Last week",
value: {
start: startOfWeek(now, locale).subtract({ weeks: 1 }),
end: endOfWeek(now, locale).subtract({ weeks: 1 }),
},
},
thisMonth: { label: "This month", value: { start: startOfMonth(now), end: endOfMonth(now) } },
lastMonth: {
label: "Last month",
value: {
start: startOfMonth(now).subtract({ months: 1 }),
end: endOfMonth(now).subtract({ months: 1 }),
},
},
thisYear: { label: "This year", value: { start: startOfMonth(now.set({ month: 1 })), end: endOfMonth(now.set({ month: 12 })) } },
lastYear: {
label: "Last year",
value: {
start: startOfMonth(now.set({ month: 1 }).subtract({ years: 1 })),
end: endOfMonth(now.set({ month: 12 }).subtract({ years: 1 })),
},
},
allTime: {
label: "All time",
value: {
start: now.set({ year: 2000, month: 1, day: 1 }),
end: now,
},
},
}),
[locale],
);
return (
<AriaDateRangePicker aria-label="Date range picker" shouldCloseOnSelect={false} {...props} value={value} onChange={setValue}>
<AriaGroup>
<Button size="md" color="secondary" iconLeading={CalendarIcon}>
{!value ? <span className="text-placeholder">Select dates</span> : `${formattedStartDate} ${formattedEndDate}`}
</Button>
</AriaGroup>
<AriaPopover
placement="bottom right"
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",
)
}
>
<AriaDialog className="flex rounded-2xl bg-primary shadow-xl ring ring-secondary_alt focus:outline-hidden">
{({ close }) => (
<>
<div className="hidden w-38 flex-col gap-0.5 border-r border-solid border-secondary p-3 lg:flex">
{Object.values(presets).map((preset) => (
<RangePresetButton
key={preset.label}
value={preset.value}
onClick={() => {
setValue(preset.value);
setFocusedValue(preset.value.start);
}}
>
{preset.label}
</RangePresetButton>
))}
</div>
<div className="flex flex-col">
<RangeCalendar
focusedValue={focusedValue}
onFocusChange={setFocusedValue}
highlightedDates={highlightedDates}
presets={{
lastWeek: presets.lastWeek,
lastMonth: presets.lastMonth,
lastYear: presets.lastYear,
}}
/>
<div className="flex justify-between gap-3 border-t border-secondary p-4">
<div className="hidden items-center gap-3 md:flex">
<DateInput slot="start" className="w-36" />
<div className="text-md text-quaternary"></div>
<DateInput slot="end" className="w-36" />
</div>
<div className="grid w-full grid-cols-2 gap-3 md:flex md:w-auto">
<Button
size="md"
color="secondary"
onClick={() => {
onCancel?.();
close();
}}
>
Cancel
</Button>
<Button
size="md"
color="primary"
onClick={() => {
onApply?.();
close();
}}
>
Apply
</Button>
</div>
</div>
</div>
</>
)}
</AriaDialog>
</AriaPopover>
</AriaDateRangePicker>
);
};

View File

@@ -0,0 +1,159 @@
import type { HTMLAttributes, PropsWithChildren } from "react";
import { Fragment, useContext, useState } from "react";
import type { CalendarDate } from "@internationalized/date";
import { ChevronLeft, ChevronRight } from "@untitledui/icons";
import { useDateFormatter } from "react-aria";
import type { RangeCalendarProps as AriaRangeCalendarProps, DateValue } from "react-aria-components";
import {
CalendarGrid as AriaCalendarGrid,
CalendarGridBody as AriaCalendarGridBody,
CalendarGridHeader as AriaCalendarGridHeader,
CalendarHeaderCell as AriaCalendarHeaderCell,
RangeCalendar as AriaRangeCalendar,
RangeCalendarContext,
RangeCalendarStateContext,
useSlottedContext,
} from "react-aria-components";
import { Button } from "@/components/base/buttons/button";
import { useBreakpoint } from "@/hooks/use-breakpoint";
import { CalendarCell } from "./cell";
import { DateInput } from "./date-input";
export const RangeCalendarContextProvider = ({ children }: PropsWithChildren) => {
const [value, onChange] = useState<{ start: DateValue; end: DateValue } | null>(null);
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();
return <RangeCalendarContext.Provider value={{ value, onChange, focusedValue, onFocusChange }}>{children}</RangeCalendarContext.Provider>;
};
const RangeCalendarTitle = ({ part }: { part: "start" | "end" }) => {
const context = useContext(RangeCalendarStateContext);
if (!context) {
throw new Error("<RangeCalendarTitle /> must be used within a <RangeCalendar /> component.");
}
const formatter = useDateFormatter({
month: "long",
year: "numeric",
calendar: context.visibleRange.start.calendar.identifier,
timeZone: context.timeZone,
});
return part === "start"
? formatter.format(context.visibleRange.start.toDate(context.timeZone))
: formatter.format(context.visibleRange.end.toDate(context.timeZone));
};
const MobilePresetButton = ({ value, children, ...props }: HTMLAttributes<HTMLButtonElement> & { value: { start: DateValue; end: DateValue } }) => {
const context = useContext(RangeCalendarStateContext);
return (
<Button
{...props}
slot={null}
size="sm"
color="link-color"
onClick={() => {
context?.setValue(value);
context?.setFocusedDate(value.start as CalendarDate);
}}
>
{children}
</Button>
);
};
interface RangeCalendarProps extends AriaRangeCalendarProps<DateValue> {
/** The dates to highlight. */
highlightedDates?: DateValue[];
/** The date presets to display. */
presets?: Record<string, { label: string; value: { start: DateValue; end: DateValue } }>;
}
export const RangeCalendar = ({ presets, ...props }: RangeCalendarProps) => {
const isDesktop = useBreakpoint("md");
const context = useSlottedContext(RangeCalendarContext);
const ContextWrapper = context ? Fragment : RangeCalendarContextProvider;
return (
<ContextWrapper>
<AriaRangeCalendar
className="flex items-start"
visibleDuration={{
months: isDesktop ? 2 : 1,
}}
{...props}
>
<div className="flex flex-col gap-3 px-6 py-5">
<header className="relative flex items-center justify-between md:justify-start">
<Button slot="previous" iconLeading={ChevronLeft} size="sm" color="tertiary" className="size-8" />
<h2 className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-sm font-semibold text-fg-secondary">
<RangeCalendarTitle part="start" />
</h2>
<Button slot="next" iconLeading={ChevronRight} size="sm" color="tertiary" className="size-8 md:hidden" />
</header>
{!isDesktop && (
<div className="flex items-center gap-2 md:hidden">
<DateInput slot="start" className="flex-1" />
<div className="text-md text-quaternary"></div>
<DateInput slot="end" className="flex-1" />
</div>
)}
{!isDesktop && presets && (
<div className="mt-1 flex justify-between gap-3 px-2 md:hidden">
{Object.values(presets).map((preset) => (
<MobilePresetButton key={preset.label} value={preset.value}>
{preset.label}
</MobilePresetButton>
))}
</div>
)}
<AriaCalendarGrid weekdayStyle="short" className="w-max">
<AriaCalendarGridHeader>
{(day) => (
<AriaCalendarHeaderCell className="border-b-4 border-transparent p-0">
<div className="flex size-10 items-center justify-center text-sm font-medium text-secondary">{day.slice(0, 2)}</div>
</AriaCalendarHeaderCell>
)}
</AriaCalendarGridHeader>
<AriaCalendarGridBody className="[&_td]:p-0 [&_tr]:border-b-4 [&_tr]:border-transparent [&_tr:last-of-type]:border-none">
{(date) => <CalendarCell date={date} />}
</AriaCalendarGridBody>
</AriaCalendarGrid>
</div>
{isDesktop && (
<div className="flex flex-col gap-3 border-l border-secondary px-6 py-5">
<header className="relative flex items-center justify-end">
<h2 className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-sm font-semibold text-fg-secondary">
<RangeCalendarTitle part="end" />
</h2>
<Button slot="next" iconLeading={ChevronRight} size="sm" color="tertiary" className="size-8" />
</header>
<AriaCalendarGrid weekdayStyle="short" offset={{ months: 1 }} className="w-max">
<AriaCalendarGridHeader>
{(day) => (
<AriaCalendarHeaderCell className="border-b-4 border-transparent p-0">
<div className="flex size-10 items-center justify-center text-sm font-medium text-secondary">{day.slice(0, 2)}</div>
</AriaCalendarHeaderCell>
)}
</AriaCalendarGridHeader>
<AriaCalendarGridBody className="[&_td]:p-0 [&_tr]:border-b-4 [&_tr]:border-transparent [&_tr:last-of-type]:border-none">
{(date) => <CalendarCell date={date} />}
</AriaCalendarGridBody>
</AriaCalendarGrid>
</div>
)}
</AriaRangeCalendar>
</ContextWrapper>
);
};

View File

@@ -0,0 +1,26 @@
import { type HTMLAttributes } from "react";
import { type DateValue, RangeCalendarContext, useSlottedContext } from "react-aria-components";
import { cx } from "@/utils/cx";
interface RangePresetButtonProps extends HTMLAttributes<HTMLButtonElement> {
value: { start: DateValue; end: DateValue };
}
export const RangePresetButton = ({ value, className, children, ...props }: RangePresetButtonProps) => {
const context = useSlottedContext(RangeCalendarContext);
const isSelected = context?.value?.start?.compare(value.start) === 0 && context?.value?.end?.compare(value.end) === 0;
return (
<button
{...props}
className={cx(
"cursor-pointer rounded-md px-3 py-2 text-left text-sm font-medium outline-focus-ring transition duration-100 ease-linear focus-visible:outline-2 focus-visible:outline-offset-2",
isSelected ? "bg-active text-secondary_hover hover:bg-secondary_hover" : "text-secondary hover:bg-primary_hover hover:text-secondary_hover",
className,
)}
>
{children}
</button>
);
};

View File

@@ -0,0 +1,144 @@
import type { ComponentProps, ComponentPropsWithRef } from "react";
import { Children, createContext, isValidElement, useContext } from "react";
import { FileIcon } from "@untitledui/file-icons";
import { SearchLg } from "@untitledui/icons";
import { FeaturedIcon as FeaturedIconbase } from "@/components/foundations/featured-icon/featured-icon";
import type { BackgroundPatternProps } from "@/components/shared-assets/background-patterns";
import { BackgroundPattern } from "@/components/shared-assets/background-patterns";
import { Illustration as Illustrations } from "@/components/shared-assets/illustrations";
import { cx } from "@/utils/cx";
interface RootContextProps {
size?: "sm" | "md" | "lg";
}
const RootContext = createContext<RootContextProps>({ size: "lg" });
interface RootProps extends ComponentPropsWithRef<"div">, RootContextProps {}
const Root = ({ size = "lg", ...props }: RootProps) => {
return (
<RootContext.Provider value={{ size }}>
<div {...props} className={cx("mx-auto flex w-full max-w-lg flex-col items-center justify-center", props.className)} />
</RootContext.Provider>
);
};
const FeaturedIcon = ({ color = "gray", theme = "modern", icon = SearchLg, size = "lg", ...props }: ComponentPropsWithRef<typeof FeaturedIconbase>) => {
const { size: rootSize } = useContext(RootContext);
return <FeaturedIconbase {...props} {...{ color, theme, icon }} size={rootSize === "lg" ? "xl" : size} />;
};
const Illustration = ({ type = "cloud", color = "gray", size = "lg", ...props }: ComponentPropsWithRef<typeof Illustrations>) => {
const { size: rootSize } = useContext(RootContext);
return (
<Illustrations
role="img"
{...props}
{...{ type, color }}
size={rootSize === "sm" ? "sm" : rootSize === "md" ? "md" : size}
className={cx("z-10", props.className)}
/>
);
};
interface FileTypeIconProps extends ComponentPropsWithRef<"div"> {
type?: ComponentProps<typeof FileIcon>["type"];
theme?: ComponentProps<typeof FileIcon>["variant"];
}
const FileTypeIcon = ({ type = "folder", theme = "solid", ...props }: FileTypeIconProps) => {
return (
<div {...props} className={cx("relative z-10 flex rounded-full bg-linear-to-b from-gray-50 to-gray-200 p-8", props.className)}>
<FileIcon type={type} variant={theme} className="size-10 drop-shadow-sm" />
</div>
);
};
interface HeaderProps extends ComponentPropsWithRef<"div"> {
pattern?: "none" | BackgroundPatternProps["pattern"];
patternSize?: "sm" | "md" | "lg";
}
const Header = ({ pattern = "circle", patternSize = "md", ...props }: HeaderProps) => {
const { size } = useContext(RootContext);
// Whether we are passing `Illustration` component as children.
const hasIllustration = Children.toArray(props.children).some((headerChild) => isValidElement(headerChild) && headerChild.type === Illustration);
return (
<header
{...props}
className={cx("relative mb-4", (size === "md" || size === "lg") && "mb-5", hasIllustration && size === "lg" && "mb-6!", props.className)}
>
{pattern !== "none" && (
<BackgroundPattern size={patternSize} pattern={pattern} className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
)}
{props.children}
</header>
);
};
const Content = (props: ComponentPropsWithRef<"div">) => {
const { size } = useContext(RootContext);
return (
<main
{...props}
className={cx(
"z-10 mb-6 flex w-full max-w-88 flex-col items-center justify-center gap-1",
(size === "md" || size === "lg") && "mb-8 gap-2",
props.className,
)}
/>
);
};
const Footer = (props: ComponentPropsWithRef<"div">) => {
return <footer {...props} className={cx("z-10 flex gap-3", props.className)} />;
};
const Title = (props: ComponentPropsWithRef<"h1">) => {
const { size } = useContext(RootContext);
return (
<h1
{...props}
className={cx(
"text-md font-semibold text-primary",
size === "md" && "text-lg font-semibold",
size === "lg" && "text-xl font-semibold",
props.className,
)}
/>
);
};
const Description = (props: ComponentPropsWithRef<"p">) => {
const { size } = useContext(RootContext);
return <p {...props} className={cx("text-center text-sm text-tertiary", size === "lg" && "text-md", props.className)} />;
};
const EmptyState = Root as typeof Root & {
Title: typeof Title;
Header: typeof Header;
Footer: typeof Footer;
Content: typeof Content;
Description: typeof Description;
Illustration: typeof Illustration;
FeaturedIcon: typeof FeaturedIcon;
FileTypeIcon: typeof FileTypeIcon;
};
EmptyState.Title = Title;
EmptyState.Header = Header;
EmptyState.Footer = Footer;
EmptyState.Content = Content;
EmptyState.Description = Description;
EmptyState.Illustration = Illustration;
EmptyState.FeaturedIcon = FeaturedIcon;
EmptyState.FileTypeIcon = FileTypeIcon;
export { EmptyState };

View File

@@ -0,0 +1,394 @@
import type { ComponentProps, ComponentPropsWithRef } from "react";
import { useId, useRef, useState } from "react";
import type { FileIcon } from "@untitledui/file-icons";
import { FileIcon as FileTypeIcon } from "@untitledui/file-icons";
import { CheckCircle, Trash01, UploadCloud02, XCircle } from "@untitledui/icons";
import { AnimatePresence, motion } from "motion/react";
import { Button } from "@/components/base/buttons/button";
import { ButtonUtility } from "@/components/base/buttons/button-utility";
import { ProgressBar } from "@/components/base/progress-indicators/progress-indicators";
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
import { cx } from "@/utils/cx";
/**
* Returns a human-readable file size.
* @param bytes - The size of the file in bytes.
* @returns A string representing the file size in a human-readable format.
*/
export const getReadableFileSize = (bytes: number) => {
if (bytes === 0) return "0 KB";
const suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.floor(bytes / Math.pow(1024, i)) + " " + suffixes[i];
};
interface FileUploadDropZoneProps {
/** The class name of the drop zone. */
className?: string;
/**
* A hint text explaining what files can be dropped.
*/
hint?: string;
/**
* Disables dropping or uploading files.
*/
isDisabled?: boolean;
/**
* Specifies the types of files that the server accepts.
* Examples: "image/*", ".pdf,image/*", "image/*,video/mpeg,application/pdf"
*/
accept?: string;
/**
* Allows multiple file uploads.
*/
allowsMultiple?: boolean;
/**
* Maximum file size in bytes.
*/
maxSize?: number;
/**
* Callback function that is called with the list of dropped files
* when files are dropped on the drop zone.
*/
onDropFiles?: (files: FileList) => void;
/**
* Callback function that is called with the list of unaccepted files
* when files are dropped on the drop zone.
*/
onDropUnacceptedFiles?: (files: FileList) => void;
/**
* Callback function that is called with the list of files that exceed
* the size limit when files are dropped on the drop zone.
*/
onSizeLimitExceed?: (files: FileList) => void;
}
export const FileUploadDropZone = ({
className,
hint,
isDisabled,
accept,
allowsMultiple = true,
maxSize,
onDropFiles,
onDropUnacceptedFiles,
onSizeLimitExceed,
}: FileUploadDropZoneProps) => {
const id = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [isInvalid, setIsInvalid] = useState(false);
const [isDraggingOver, setIsDraggingOver] = useState(false);
const isFileTypeAccepted = (file: File): boolean => {
if (!accept) return true;
// Split the accept string into individual types
const acceptedTypes = accept.split(",").map((type) => type.trim());
return acceptedTypes.some((acceptedType) => {
// Handle file extensions (e.g., .pdf, .doc)
if (acceptedType.startsWith(".")) {
const extension = `.${file.name.split(".").pop()?.toLowerCase()}`;
return extension === acceptedType.toLowerCase();
}
// Handle wildcards (e.g., image/*)
if (acceptedType.endsWith("/*")) {
const typePrefix = acceptedType.split("/")[0];
return file.type.startsWith(`${typePrefix}/`);
}
// Handle exact MIME types (e.g., application/pdf)
return file.type === acceptedType;
});
};
const handleDragIn = (event: React.DragEvent<HTMLDivElement>) => {
if (isDisabled) return;
event.preventDefault();
event.stopPropagation();
setIsDraggingOver(true);
};
const handleDragOut = (event: React.DragEvent<HTMLDivElement>) => {
if (isDisabled) return;
event.preventDefault();
event.stopPropagation();
setIsDraggingOver(false);
};
const processFiles = (files: File[]): void => {
// Reset the invalid state when processing files.
setIsInvalid(false);
const acceptedFiles: File[] = [];
const unacceptedFiles: File[] = [];
const oversizedFiles: File[] = [];
// If multiple files are not allowed, only process the first file
const filesToProcess = allowsMultiple ? files : files.slice(0, 1);
filesToProcess.forEach((file) => {
// Check file size first
if (maxSize && file.size > maxSize) {
oversizedFiles.push(file);
return;
}
// Then check file type
if (isFileTypeAccepted(file)) {
acceptedFiles.push(file);
} else {
unacceptedFiles.push(file);
}
});
// Handle oversized files
if (oversizedFiles.length > 0 && typeof onSizeLimitExceed === "function") {
const dataTransfer = new DataTransfer();
oversizedFiles.forEach((file) => dataTransfer.items.add(file));
setIsInvalid(true);
onSizeLimitExceed(dataTransfer.files);
}
// Handle accepted files
if (acceptedFiles.length > 0 && typeof onDropFiles === "function") {
const dataTransfer = new DataTransfer();
acceptedFiles.forEach((file) => dataTransfer.items.add(file));
onDropFiles(dataTransfer.files);
}
// Handle unaccepted files
if (unacceptedFiles.length > 0 && typeof onDropUnacceptedFiles === "function") {
const unacceptedDataTransfer = new DataTransfer();
unacceptedFiles.forEach((file) => unacceptedDataTransfer.items.add(file));
setIsInvalid(true);
onDropUnacceptedFiles(unacceptedDataTransfer.files);
}
// Clear the input value to ensure the same file can be selected again
if (inputRef.current) {
inputRef.current.value = "";
}
};
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
if (isDisabled) return;
handleDragOut(event);
processFiles(Array.from(event.dataTransfer.files));
};
const handleInputFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
processFiles(Array.from(event.target.files || []));
};
return (
<div
data-dropzone
onDragOver={handleDragIn}
onDragEnter={handleDragIn}
onDragLeave={handleDragOut}
onDragEnd={handleDragOut}
onDrop={handleDrop}
className={cx(
"relative flex flex-col items-center gap-3 rounded-xl bg-primary px-6 py-4 text-tertiary ring-1 ring-secondary transition duration-100 ease-linear ring-inset",
isDraggingOver && "ring-2 ring-brand",
isDisabled && "cursor-not-allowed bg-disabled_subtle ring-disabled_subtle",
className,
)}
>
<FeaturedIcon color="gray" theme="modern" size="md">
<UploadCloud02 className="size-5" />
</FeaturedIcon>
<div className="flex flex-col gap-1 text-center">
<div className="flex justify-center gap-1 text-center">
<input
ref={inputRef}
id={id}
type="file"
className="peer sr-only"
disabled={isDisabled}
accept={accept}
multiple={allowsMultiple}
onChange={handleInputFileChange}
/>
<label htmlFor={id} className="flex cursor-pointer">
<Button color="link-color" size="md" isDisabled={isDisabled} onClick={() => inputRef.current?.click()}>
Click to upload <span className="md:hidden">and attach files</span>
</Button>
</label>
<span className="text-sm max-md:hidden">or drag and drop</span>
</div>
<p className={cx("text-xs transition duration-100 ease-linear", isInvalid && "text-error-primary")}>
{hint || "SVG, PNG, JPG or GIF (max. 800x400px)"}
</p>
</div>
</div>
);
};
export interface FileListItemProps {
/** The name of the file. */
name: string;
/** The size of the file. */
size: number;
/** The upload progress of the file. */
progress: number;
/** Whether the file failed to upload. */
failed?: boolean;
/** The type of the file. */
type?: ComponentProps<typeof FileIcon>["type"];
/** The class name of the file list item. */
className?: string;
/** The variant of the file icon. */
fileIconVariant?: ComponentProps<typeof FileTypeIcon>["variant"];
/** The function to call when the file is deleted. */
onDelete?: () => void;
/** The function to call when the file upload is retried. */
onRetry?: () => void;
}
export const FileListItemProgressBar = ({ name, size, progress, failed, type, fileIconVariant, onDelete, onRetry, className }: FileListItemProps) => {
const isComplete = progress === 100;
return (
<motion.li
layout="position"
className={cx(
"relative flex gap-3 rounded-xl bg-primary p-4 ring-1 ring-secondary transition-shadow duration-100 ease-linear ring-inset",
failed && "ring-2 ring-error",
className,
)}
>
<FileTypeIcon className="size-10 shrink-0 dark:hidden" type={type ?? "empty"} theme="light" variant={fileIconVariant ?? "default"} />
<FileTypeIcon className="size-10 shrink-0 not-dark:hidden" type={type ?? "empty"} theme="dark" variant={fileIconVariant ?? "default"} />
<div className="flex min-w-0 flex-1 flex-col items-start">
<div className="flex w-full max-w-full min-w-0 flex-1">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-secondary">{name}</p>
<div className="mt-0.5 flex items-center gap-2">
<p className="truncate text-sm whitespace-nowrap text-tertiary">{getReadableFileSize(size)}</p>
<hr className="h-3 w-px rounded-t-full rounded-b-full border-none bg-border-primary" />
<div className="flex items-center gap-1">
{isComplete && <CheckCircle className="size-4 stroke-[2.5px] text-fg-success-primary" />}
{isComplete && <p className="text-sm font-medium text-success-primary">Complete</p>}
{!isComplete && !failed && <UploadCloud02 className="stroke-[2.5px size-4 text-fg-quaternary" />}
{!isComplete && !failed && <p className="text-sm font-medium text-quaternary">Uploading...</p>}
{failed && <XCircle className="size-4 text-fg-error-primary" />}
{failed && <p className="text-sm font-medium text-error-primary">Failed</p>}
</div>
</div>
</div>
<ButtonUtility color="tertiary" tooltip="Delete" icon={Trash01} size="xs" className="-mt-2 -mr-2 self-start" onClick={onDelete} />
</div>
{!failed && (
<div className="mt-1 w-full">
<ProgressBar labelPosition="right" max={100} min={0} value={progress} />
</div>
)}
{failed && (
<Button color="link-destructive" size="sm" onClick={onRetry} className="mt-1.5">
Try again
</Button>
)}
</div>
</motion.li>
);
};
export const FileListItemProgressFill = ({ name, size, progress, failed, type, fileIconVariant, onDelete, onRetry, className }: FileListItemProps) => {
const isComplete = progress === 100;
return (
<motion.li layout="position" className={cx("relative flex gap-3 overflow-hidden rounded-xl bg-primary p-4", className)}>
{/* Progress fill. */}
<div
style={{ transform: `translateX(-${100 - progress}%)` }}
className={cx("absolute inset-0 size-full bg-secondary transition duration-75 ease-linear", isComplete && "opacity-0")}
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
/>
{/* Inner ring. */}
<div
className={cx(
"absolute inset-0 size-full rounded-[inherit] ring-1 ring-secondary transition duration-100 ease-linear ring-inset",
failed && "ring-2 ring-error",
)}
/>
<FileTypeIcon className="relative size-10 shrink-0 dark:hidden" type={type ?? "empty"} theme="light" variant={fileIconVariant ?? "solid"} />
<FileTypeIcon className="relative size-10 shrink-0 not-dark:hidden" type={type ?? "empty"} theme="dark" variant={fileIconVariant ?? "solid"} />
<div className="relative flex min-w-0 flex-1">
<div className="relative flex min-w-0 flex-1 flex-col items-start">
<div className="w-full min-w-0 flex-1">
<p className="truncate text-sm font-medium text-secondary">{name}</p>
<div className="mt-0.5 flex items-center gap-2">
<p className="text-sm text-tertiary">{failed ? "Upload failed, please try again" : getReadableFileSize(size)}</p>
{!failed && (
<>
<hr className="h-3 w-px rounded-t-full rounded-b-full border-none bg-border-primary" />
<div className="flex items-center gap-1">
{isComplete && <CheckCircle className="size-4 stroke-[2.5px] text-fg-success-primary" />}
{!isComplete && <UploadCloud02 className="size-4 stroke-[2.5px] text-fg-quaternary" />}
<p className="text-sm text-tertiary">{progress}%</p>
</div>
</>
)}
</div>
</div>
{failed && (
<Button color="link-destructive" size="sm" onClick={onRetry} className="mt-1.5">
Try again
</Button>
)}
</div>
<ButtonUtility color="tertiary" tooltip="Delete" icon={Trash01} size="xs" className="-mt-2 -mr-2 self-start" onClick={onDelete} />
</div>
</motion.li>
);
};
const FileUploadRoot = (props: ComponentPropsWithRef<"div">) => (
<div {...props} className={cx("flex flex-col gap-4", props.className)}>
{props.children}
</div>
);
const FileUploadList = (props: ComponentPropsWithRef<"ul">) => (
<ul {...props} className={cx("flex flex-col gap-3", props.className)}>
<AnimatePresence initial={false}>{props.children}</AnimatePresence>
</ul>
);
export const FileUpload = {
Root: FileUploadRoot,
List: FileUploadList,
DropZone: FileUploadDropZone,
ListItemProgressBar: FileListItemProgressBar,
ListItemProgressFill: FileListItemProgressFill,
};

View File

@@ -0,0 +1,121 @@
import { cx } from "@/utils/cx";
const styles = {
sm: {
root: "gap-4",
label: "text-sm font-medium",
spinner: "size-8",
},
md: {
root: "gap-4",
label: "text-sm font-medium",
spinner: "size-12",
},
lg: {
root: "gap-4",
label: "text-lg font-medium",
spinner: "size-14",
},
xl: {
root: "gap-5",
label: "text-lg font-medium",
spinner: "size-16",
},
};
interface LoadingIndicatorProps {
/**
* The visual style of the loading indicator.
* @default 'line-simple'
*/
type?: "line-simple" | "line-spinner" | "dot-circle";
/**
* The size of the loading indicator.
* @default 'sm'
*/
size?: "sm" | "md" | "lg" | "xl";
/**
* Optional text label displayed below the indicator.
*/
label?: string;
}
export const LoadingIndicator = ({ type = "line-simple", size = "sm", label }: LoadingIndicatorProps) => {
const renderSpinner = () => {
if (type === "line-spinner") {
return (
<svg className={cx("animate-spin", styles[size].spinner)} viewBox="0 0 32 32" fill="none">
<circle
className="stroke-fg-brand-primary"
cx="16"
cy="16"
r="14"
fill="none"
strokeWidth="4"
strokeDashoffset="40"
strokeDasharray="100"
strokeLinecap="round"
/>
</svg>
);
}
if (type === "dot-circle") {
return (
<svg className={cx("animate-spin text-fg-brand-primary", styles[size].spinner)} viewBox="0 0 36 36" fill="none">
<path
d="M34 18C34 15.8989 33.5861 13.8183 32.7821 11.8771C31.978 9.93586 30.7994 8.17203 29.3137 6.68629C27.828 5.20055 26.0641 4.022 24.1229 3.21793C22.1817 2.41385 20.1011 2 18 2C15.8988 2 13.8183 2.41385 11.8771 3.21793C9.93585 4.022 8.17203 5.20055 6.68629 6.68629C5.20055 8.17203 4.022 9.93586 3.21793 11.8771C2.41385 13.8183 2 15.8989 2 18"
stroke="url(#paint0)"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray="0.1 8"
/>
<path
d="M3.21793 24.1229C4.022 26.0641 5.20055 27.828 6.68629 29.3137C8.17203 30.7994 9.93585 31.978 11.8771 32.7821C13.8183 33.5861 15.8988 34 18 34C20.1011 34 22.1817 33.5861 24.1229 32.7821C26.0641 31.978 27.828 30.7994 29.3137 29.3137C30.7994 27.828 31.978 26.0641 32.7821 24.1229"
stroke="url(#paint1)"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray="0.1 8"
/>
<defs>
<linearGradient id="paint0" x1="34" y1="18" x2="2" y2="18" gradientUnits="userSpaceOnUse">
<stop stopColor="currentColor" />
<stop offset="1" stopColor="currentColor" stopOpacity="0.5" />
</linearGradient>
<linearGradient id="paint1" x1="33" y1="23.5" x2="3" y2="24" gradientUnits="userSpaceOnUse">
<stop stopOpacity="0" stopColor="currentColor" />
<stop offset="1" stopColor="currentColor" stopOpacity="0.48" />
</linearGradient>
</defs>
</svg>
);
}
// Default case: type === "line-simple"
return (
<svg className={cx("animate-spin", styles[size].spinner)} viewBox="0 0 32 32" fill="none">
<circle className="text-bg-tertiary" cx="16" cy="16" r="14" stroke="currentColor" strokeWidth="4" />
<circle
className="stroke-fg-brand-primary"
cx="16"
cy="16"
r="14"
fill="none"
strokeWidth="4"
strokeDashoffset="75"
strokeDasharray="100"
strokeLinecap="round"
/>
</svg>
);
};
return (
<div className={cx("flex flex-col items-center justify-center", styles[size].root)}>
{renderSpinner()}
{label && <span className={cx("text-secondary", styles[size].label)}>{label}</span>}
</div>
);
};

View File

@@ -0,0 +1,39 @@
import type { DialogProps as AriaDialogProps, ModalOverlayProps as AriaModalOverlayProps } from "react-aria-components";
import { Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Modal as AriaModal, ModalOverlay as AriaModalOverlay } from "react-aria-components";
import { cx } from "@/utils/cx";
export const DialogTrigger = AriaDialogTrigger;
export const ModalOverlay = (props: AriaModalOverlayProps) => {
return (
<AriaModalOverlay
{...props}
className={(state) =>
cx(
"fixed inset-0 z-50 flex min-h-dvh w-full items-end justify-center overflow-y-auto bg-overlay/70 px-4 pt-4 pb-[clamp(16px,8vh,64px)] outline-hidden backdrop-blur-[6px] sm:items-center sm:justify-center sm:p-8",
state.isEntering && "duration-300 ease-out animate-in fade-in",
state.isExiting && "duration-200 ease-in animate-out fade-out",
typeof props.className === "function" ? props.className(state) : props.className,
)
}
/>
);
};
export const Modal = (props: AriaModalOverlayProps) => (
<AriaModal
{...props}
className={(state) =>
cx(
"max-h-full w-full align-middle outline-hidden max-sm:overflow-y-auto max-sm:rounded-xl",
state.isEntering && "duration-300 ease-out animate-in zoom-in-95",
state.isExiting && "duration-200 ease-in animate-out zoom-out-95",
typeof props.className === "function" ? props.className(state) : props.className,
)
}
/>
);
export const Dialog = (props: AriaDialogProps) => (
<AriaDialog {...props} className={cx("flex w-full items-center justify-center outline-hidden", props.className)} />
);

View File

@@ -0,0 +1,376 @@
import type { CSSProperties, FC, HTMLAttributes, ReactNode } from "react";
import React, { cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useState } from "react";
type PaginationPage = {
/** The type of the pagination item. */
type: "page";
/** The value of the pagination item. */
value: number;
/** Whether the pagination item is the current page. */
isCurrent: boolean;
};
type PaginationEllipsisType = {
type: "ellipsis";
key: number;
};
type PaginationItemType = PaginationPage | PaginationEllipsisType;
interface PaginationContextType {
/** The pages of the pagination. */
pages: PaginationItemType[];
/** The current page of the pagination. */
currentPage: number;
/** The total number of pages. */
total: number;
/** The function to call when the page changes. */
onPageChange: (page: number) => void;
}
const PaginationContext = createContext<PaginationContextType | undefined>(undefined);
export interface PaginationRootProps {
/** Number of sibling pages to show on each side of the current page */
siblingCount?: number;
/** Current active page number */
page: number;
/** Total number of pages */
total: number;
children: ReactNode;
/** The style of the pagination root. */
style?: CSSProperties;
/** The class name of the pagination root. */
className?: string;
/** Callback function that's called when the page changes with the new page number. */
onPageChange?: (page: number) => void;
}
const PaginationRoot = ({ total, siblingCount = 1, page, onPageChange, children, style, className }: PaginationRootProps) => {
const [pages, setPages] = useState<PaginationItemType[]>([]);
const createPaginationItems = useCallback((): PaginationItemType[] => {
const items: PaginationItemType[] = [];
// Calculate the maximum number of pagination elements (pages, potential ellipsis, first and last) to show
const totalPageNumbers = siblingCount * 2 + 5;
// If the total number of items to show is greater than or equal to the total pages,
// we can simply list all pages without needing to collapse with ellipsis
if (totalPageNumbers >= total) {
for (let i = 1; i <= total; i++) {
items.push({
type: "page",
value: i,
isCurrent: i === page,
});
}
} else {
// Calculate left and right sibling boundaries around the current page
const leftSiblingIndex = Math.max(page - siblingCount, 1);
const rightSiblingIndex = Math.min(page + siblingCount, total);
// Determine if we need to show ellipsis on either side
const showLeftEllipsis = leftSiblingIndex > 2;
const showRightEllipsis = rightSiblingIndex < total - 1;
// Case 1: No left ellipsis, but right ellipsis is needed
if (!showLeftEllipsis && showRightEllipsis) {
// Calculate how many page numbers to show starting from the beginning
const leftItemCount = siblingCount * 2 + 3;
const leftRange = range(1, leftItemCount);
leftRange.forEach((pageNum) =>
items.push({
type: "page",
value: pageNum,
isCurrent: pageNum === page,
}),
);
// Insert ellipsis after the left range and add the last page
items.push({ type: "ellipsis", key: leftItemCount + 1 });
items.push({
type: "page",
value: total,
isCurrent: total === page,
});
}
// Case 2: Left ellipsis needed, but right ellipsis is not needed
else if (showLeftEllipsis && !showRightEllipsis) {
// Determine how many items from the end should be shown
const rightItemCount = siblingCount * 2 + 3;
const rightRange = range(total - rightItemCount + 1, total);
// Always show the first page, then add an ellipsis to indicate skipped pages
items.push({
type: "page",
value: 1,
isCurrent: page === 1,
});
items.push({ type: "ellipsis", key: total - rightItemCount });
rightRange.forEach((pageNum) =>
items.push({
type: "page",
value: pageNum,
isCurrent: pageNum === page,
}),
);
}
// Case 3: Both left and right ellipsis are needed
else if (showLeftEllipsis && showRightEllipsis) {
// Always show the first page
items.push({
type: "page",
value: 1,
isCurrent: page === 1,
});
// Insert left ellipsis after the first page
items.push({ type: "ellipsis", key: leftSiblingIndex - 1 });
// Show a range of pages around the current page
const middleRange = range(leftSiblingIndex, rightSiblingIndex);
middleRange.forEach((pageNum) =>
items.push({
type: "page",
value: pageNum,
isCurrent: pageNum === page,
}),
);
// Insert right ellipsis and finally the last page
items.push({ type: "ellipsis", key: rightSiblingIndex + 1 });
items.push({
type: "page",
value: total,
isCurrent: total === page,
});
}
}
return items;
}, [total, siblingCount, page]);
useEffect(() => {
const paginationItems = createPaginationItems();
setPages(paginationItems);
}, [createPaginationItems]);
const onPageChangeHandler = (newPage: number) => {
onPageChange?.(newPage);
};
const paginationContextValue: PaginationContextType = {
pages,
currentPage: page,
total,
onPageChange: onPageChangeHandler,
};
return (
<PaginationContext.Provider value={paginationContextValue}>
<nav aria-label="Pagination Navigation" style={style} className={className}>
{children}
</nav>
</PaginationContext.Provider>
);
};
/**
* Creates an array of numbers from start to end.
* @param start - The start number.
* @param end - The end number.
* @returns An array of numbers from start to end.
*/
const range = (start: number, end: number): number[] => {
const length = end - start + 1;
return Array.from({ length }, (_, index) => index + start);
};
interface TriggerRenderProps {
isDisabled: boolean;
onClick: () => void;
}
interface TriggerProps {
/** The children of the trigger. Can be a render prop or a valid element. */
children: ReactNode | ((props: TriggerRenderProps) => ReactNode);
/** The style of the trigger. */
style?: CSSProperties;
/** The class name of the trigger. */
className?: string | ((args: { isDisabled: boolean }) => string);
/** If true, the child element will be cloned and passed down the prop of the trigger. */
asChild?: boolean;
/** The direction of the trigger. */
direction: "prev" | "next";
/** The aria label of the trigger. */
ariaLabel?: string;
}
const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false, direction, ariaLabel }) => {
const context = useContext(PaginationContext);
if (!context) {
throw new Error("Pagination components must be used within a Pagination.Root");
}
const { currentPage, total, onPageChange } = context;
const isDisabled = direction === "prev" ? currentPage <= 1 : currentPage >= total;
const handleClick = () => {
if (isDisabled) return;
const newPage = direction === "prev" ? currentPage - 1 : currentPage + 1;
onPageChange?.(newPage);
};
const computedClassName = typeof className === "function" ? className({ isDisabled }) : className;
const defaultAriaLabel = direction === "prev" ? "Previous Page" : "Next Page";
// If the children is a render prop, we need to pass the isDisabled and onClick to the render prop.
if (typeof children === "function") {
return <>{children({ isDisabled, onClick: handleClick })}</>;
}
// If the children is a valid element, we need to clone it and pass the isDisabled and onClick to the cloned element.
if (asChild && isValidElement(children)) {
return cloneElement(children, {
onClick: handleClick,
disabled: isDisabled,
isDisabled,
"aria-label": ariaLabel || defaultAriaLabel,
style: { ...(children.props as HTMLAttributes<HTMLElement>).style, ...style },
className: [computedClassName, (children.props as HTMLAttributes<HTMLElement>).className].filter(Boolean).join(" ") || undefined,
} as HTMLAttributes<HTMLElement>);
}
return (
<button aria-label={ariaLabel || defaultAriaLabel} onClick={handleClick} disabled={isDisabled} style={style} className={computedClassName}>
{children}
</button>
);
};
const PaginationPrevTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="prev" />;
const PaginationNextTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="next" />;
interface PaginationItemRenderProps {
isSelected: boolean;
onClick: () => void;
value: number;
"aria-current"?: "page";
"aria-label"?: string;
}
export interface PaginationItemProps {
/** The value of the pagination item. */
value: number;
/** Whether the pagination item is the current page. */
isCurrent: boolean;
/** The children of the pagination item. Can be a render prop or a valid element. */
children?: ReactNode | ((props: PaginationItemRenderProps) => ReactNode);
/** The style object of the pagination item. */
style?: CSSProperties;
/** The class name of the pagination item. */
className?: string | ((args: { isSelected: boolean }) => string);
/** The aria label of the pagination item. */
ariaLabel?: string;
/** If true, the child element will be cloned and passed down the prop of the item. */
asChild?: boolean;
}
const PaginationItem = ({ value, isCurrent, children, style, className, ariaLabel, asChild = false }: PaginationItemProps) => {
const context = useContext(PaginationContext);
if (!context) {
throw new Error("Pagination components must be used within a <Pagination.Root />");
}
const { onPageChange } = context;
const isSelected = isCurrent;
const handleClick = () => {
onPageChange?.(value);
};
const computedClassName = typeof className === "function" ? className({ isSelected }) : className;
// If the children is a render prop, we need to pass the necessary props to the render prop.
if (typeof children === "function") {
return (
<>
{children({
isSelected,
onClick: handleClick,
value,
"aria-current": isCurrent ? "page" : undefined,
"aria-label": ariaLabel || `Page ${value}`,
})}
</>
);
}
// If the children is a valid element, we need to clone it and pass the necessary props to the cloned element.
if (asChild && isValidElement(children)) {
return cloneElement(children, {
onClick: handleClick,
"aria-current": isCurrent ? "page" : undefined,
"aria-label": ariaLabel || `Page ${value}`,
style: { ...(children.props as HTMLAttributes<HTMLElement>).style, ...style },
className: [computedClassName, (children.props as HTMLAttributes<HTMLElement>).className].filter(Boolean).join(" ") || undefined,
} as HTMLAttributes<HTMLElement>);
}
return (
<button
onClick={handleClick}
style={style}
className={computedClassName}
aria-current={isCurrent ? "page" : undefined}
aria-label={ariaLabel || `Page ${value}`}
role="listitem"
>
{children}
</button>
);
};
interface PaginationEllipsisProps {
key: number;
children?: ReactNode;
style?: CSSProperties;
className?: string | (() => string);
}
const PaginationEllipsis: FC<PaginationEllipsisProps> = ({ children, style, className }) => {
const computedClassName = typeof className === "function" ? className() : className;
return (
<span style={style} className={computedClassName} aria-hidden="true">
{children}
</span>
);
};
interface PaginationContextComponentProps {
children: (pagination: PaginationContextType) => ReactNode;
}
const PaginationContextComponent: FC<PaginationContextComponentProps> = ({ children }) => {
const context = useContext(PaginationContext);
if (!context) {
throw new Error("Pagination components must be used within a Pagination.Root");
}
return <>{children(context)}</>;
};
export const Pagination = {
Root: PaginationRoot,
PrevTrigger: PaginationPrevTrigger,
NextTrigger: PaginationNextTrigger,
Item: PaginationItem,
Ellipsis: PaginationEllipsis,
Context: PaginationContextComponent,
};

View File

@@ -0,0 +1,52 @@
import { cx } from "@/utils/cx";
import type { PaginationRootProps } from "./pagination-base";
import { Pagination } from "./pagination-base";
interface PaginationDotProps extends Omit<PaginationRootProps, "children"> {
/** The size of the pagination dot. */
size?: "md" | "lg";
/** Whether the pagination uses brand colors. */
isBrand?: boolean;
/** Whether the pagination is displayed in a card. */
framed?: boolean;
}
export const PaginationDot = ({ framed, className, size = "md", isBrand, ...props }: PaginationDotProps) => {
const sizes = {
md: {
root: cx("gap-3", framed && "p-2"),
button: "h-2 w-2 after:-inset-x-1.5 after:-inset-y-2",
},
lg: {
root: cx("gap-4", framed && "p-3"),
button: "h-2.5 w-2.5 after:-inset-x-2 after:-inset-y-3",
},
};
return (
<Pagination.Root {...props} className={cx("flex h-max w-max", sizes[size].root, framed && "rounded-full bg-alpha-white/90 backdrop-blur", className)}>
<Pagination.Context>
{({ pages }) =>
pages.map((page, index) =>
page.type === "page" ? (
<Pagination.Item
{...page}
asChild
key={index}
className={cx(
"relative cursor-pointer rounded-full bg-quaternary outline-focus-ring after:absolute focus-visible:outline-2 focus-visible:outline-offset-2",
sizes[size].button,
page.isCurrent && "bg-fg-brand-primary_alt",
isBrand && "bg-fg-brand-secondary",
isBrand && page.isCurrent && "bg-fg-white",
)}
></Pagination.Item>
) : (
<Pagination.Ellipsis {...page} key={index} />
),
)
}
</Pagination.Context>
</Pagination.Root>
);
};

View File

@@ -0,0 +1,48 @@
import { cx } from "@/utils/cx";
import type { PaginationRootProps } from "./pagination-base";
import { Pagination } from "./pagination-base";
interface PaginationLineProps extends Omit<PaginationRootProps, "children"> {
/** The size of the pagination line. */
size?: "md" | "lg";
/** Whether the pagination is displayed in a card. */
framed?: boolean;
}
export const PaginationLine = ({ framed, className, size = "md", ...props }: PaginationLineProps) => {
const sizes = {
md: {
root: cx("gap-2", framed && "p-2"),
button: "h-1.5 w-full after:-inset-x-1.5 after:-inset-y-2",
},
lg: {
root: cx("gap-3", framed && "p-3"),
button: "h-2 w-full after:-inset-x-2 after:-inset-y-3",
},
};
return (
<Pagination.Root {...props} className={cx("flex h-max w-max", sizes[size].root, framed && "rounded-full bg-alpha-white/90 backdrop-blur", className)}>
<Pagination.Context>
{({ pages }) =>
pages.map((page, index) =>
page.type === "page" ? (
<Pagination.Item
{...page}
asChild
key={index}
className={cx(
"relative cursor-pointer rounded-full bg-quaternary outline-focus-ring after:absolute focus-visible:outline-2 focus-visible:outline-offset-2",
sizes[size].button,
page.isCurrent && "bg-fg-brand-primary_alt",
)}
/>
) : (
<Pagination.Ellipsis {...page} key={index} />
),
)
}
</Pagination.Context>
</Pagination.Root>
);
};

View File

@@ -0,0 +1,328 @@
import { ArrowLeft, ArrowRight } from "@untitledui/icons";
import { ButtonGroup, ButtonGroupItem } from "@/components/base/button-group/button-group";
import { Button } from "@/components/base/buttons/button";
import { useBreakpoint } from "@/hooks/use-breakpoint";
import { cx } from "@/utils/cx";
import type { PaginationRootProps } from "./pagination-base";
import { Pagination } from "./pagination-base";
interface PaginationProps extends Partial<Omit<PaginationRootProps, "children">> {
/** Whether the pagination buttons are rounded. */
rounded?: boolean;
}
const PaginationItem = ({ value, rounded, isCurrent }: { value: number; rounded?: boolean; isCurrent: boolean }) => {
return (
<Pagination.Item
value={value}
isCurrent={isCurrent}
className={({ isSelected }) =>
cx(
"flex size-10 cursor-pointer items-center justify-center p-3 text-sm font-medium text-quaternary outline-focus-ring transition duration-100 ease-linear hover:bg-primary_hover hover:text-secondary focus-visible:z-10 focus-visible:bg-primary_hover focus-visible:outline-2 focus-visible:outline-offset-2",
rounded ? "rounded-full" : "rounded-lg",
isSelected && "bg-primary_hover text-secondary",
)
}
>
{value}
</Pagination.Item>
);
};
interface MobilePaginationProps {
/** The current page. */
page?: number;
/** The total number of pages. */
total?: number;
/** The class name of the pagination component. */
className?: string;
/** The function to call when the page changes. */
onPageChange?: (page: number) => void;
}
const MobilePagination = ({ page = 1, total = 10, className, onPageChange }: MobilePaginationProps) => {
return (
<nav aria-label="Pagination" className={cx("flex items-center justify-between md:hidden", className)}>
<Button
aria-label="Go to previous page"
iconLeading={ArrowLeft}
color="secondary"
size="sm"
onClick={() => onPageChange?.(Math.max(0, page - 1))}
/>
<span className="text-sm text-fg-secondary">
Page <span className="font-medium">{page}</span> of <span className="font-medium">{total}</span>
</span>
<Button
aria-label="Go to next page"
iconLeading={ArrowRight}
color="secondary"
size="sm"
onClick={() => onPageChange?.(Math.min(total, page + 1))}
/>
</nav>
);
};
export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className, ...props }: PaginationProps) => {
const isDesktop = useBreakpoint("md");
return (
<Pagination.Root
{...props}
page={page}
total={total}
className={cx("flex w-full items-center justify-between gap-3 border-t border-secondary pt-4 md:pt-5", className)}
>
<div className="hidden flex-1 justify-start md:flex">
<Pagination.PrevTrigger asChild>
<Button iconLeading={ArrowLeft} color="link-gray" size="sm">
{isDesktop ? "Previous" : undefined}{" "}
</Button>
</Pagination.PrevTrigger>
</div>
<Pagination.PrevTrigger asChild className="md:hidden">
<Button iconLeading={ArrowLeft} color="secondary" size="sm">
{isDesktop ? "Previous" : undefined}
</Button>
</Pagination.PrevTrigger>
<Pagination.Context>
{({ pages, currentPage, total }) => (
<>
<div className="hidden justify-center gap-0.5 md:flex">
{pages.map((page, index) =>
page.type === "page" ? (
<PaginationItem key={index} rounded={rounded} {...page} />
) : (
<Pagination.Ellipsis key={index} className="flex size-10 shrink-0 items-center justify-center text-tertiary">
&#8230;
</Pagination.Ellipsis>
),
)}
</div>
<div className="flex justify-center text-sm whitespace-pre text-fg-secondary md:hidden">
Page <span className="font-medium">{currentPage}</span> of <span className="font-medium">{total}</span>
</div>
</>
)}
</Pagination.Context>
<div className="hidden flex-1 justify-end md:flex">
<Pagination.NextTrigger asChild>
<Button iconTrailing={ArrowRight} color="link-gray" size="sm">
{isDesktop ? "Next" : undefined}
</Button>
</Pagination.NextTrigger>
</div>
<Pagination.NextTrigger asChild className="md:hidden">
<Button iconTrailing={ArrowRight} color="secondary" size="sm">
{isDesktop ? "Next" : undefined}
</Button>
</Pagination.NextTrigger>
</Pagination.Root>
);
};
export const PaginationPageMinimalCenter = ({ rounded, page = 1, total = 10, className, ...props }: PaginationProps) => {
const isDesktop = useBreakpoint("md");
return (
<Pagination.Root
{...props}
page={page}
total={total}
className={cx("flex w-full items-center justify-between gap-3 border-t border-secondary pt-4 md:pt-5", className)}
>
<div className="flex flex-1 justify-start">
<Pagination.PrevTrigger asChild>
<Button iconLeading={ArrowLeft} color="secondary" size="sm">
{isDesktop ? "Previous" : undefined}
</Button>
</Pagination.PrevTrigger>
</div>
<Pagination.Context>
{({ pages, currentPage, total }) => (
<>
<div className="hidden justify-center gap-0.5 md:flex">
{pages.map((page, index) =>
page.type === "page" ? (
<PaginationItem key={index} rounded={rounded} {...page} />
) : (
<Pagination.Ellipsis key={index} className="flex size-10 shrink-0 items-center justify-center text-tertiary">
&#8230;
</Pagination.Ellipsis>
),
)}
</div>
<div className="flex justify-center text-sm whitespace-pre text-fg-secondary md:hidden">
Page <span className="font-medium">{currentPage}</span> of <span className="font-medium">{total}</span>
</div>
</>
)}
</Pagination.Context>
<div className="flex flex-1 justify-end">
<Pagination.NextTrigger asChild>
<Button iconTrailing={ArrowRight} color="secondary" size="sm">
{isDesktop ? "Next" : undefined}
</Button>
</Pagination.NextTrigger>
</div>
</Pagination.Root>
);
};
export const PaginationCardDefault = ({ rounded, page = 1, total = 10, ...props }: PaginationProps) => {
const isDesktop = useBreakpoint("md");
return (
<Pagination.Root
{...props}
page={page}
total={total}
className="flex w-full items-center justify-between gap-3 border-t border-secondary px-4 py-3 md:px-6 md:pt-3 md:pb-4"
>
<div className="flex flex-1 justify-start">
<Pagination.PrevTrigger asChild>
<Button iconLeading={ArrowLeft} color="secondary" size="sm">
{isDesktop ? "Previous" : undefined}
</Button>
</Pagination.PrevTrigger>
</div>
<Pagination.Context>
{({ pages, currentPage, total }) => (
<>
<div className="hidden justify-center gap-0.5 md:flex">
{pages.map((page, index) =>
page.type === "page" ? (
<PaginationItem key={index} rounded={rounded} {...page} />
) : (
<Pagination.Ellipsis key={index} className="flex size-10 shrink-0 items-center justify-center text-tertiary">
&#8230;
</Pagination.Ellipsis>
),
)}
</div>
<div className="flex justify-center text-sm whitespace-pre text-fg-secondary md:hidden">
Page <span className="font-medium">{currentPage}</span> of <span className="font-medium">{total}</span>
</div>
</>
)}
</Pagination.Context>
<div className="flex flex-1 justify-end">
<Pagination.NextTrigger asChild>
<Button iconTrailing={ArrowRight} color="secondary" size="sm">
{isDesktop ? "Next" : undefined}
</Button>
</Pagination.NextTrigger>
</div>
</Pagination.Root>
);
};
interface PaginationCardMinimalProps {
/** The current page. */
page?: number;
/** The total number of pages. */
total?: number;
/** The alignment of the pagination. */
align?: "left" | "center" | "right";
/** The class name of the pagination component. */
className?: string;
/** The function to call when the page changes. */
onPageChange?: (page: number) => void;
}
export const PaginationCardMinimal = ({ page = 1, total = 10, align = "left", onPageChange, className }: PaginationCardMinimalProps) => {
return (
<div className={cx("border-t border-secondary px-4 py-3 md:px-6 md:pt-3 md:pb-4", className)}>
<MobilePagination page={page} total={total} onPageChange={onPageChange} />
<nav aria-label="Pagination" className={cx("hidden items-center gap-3 md:flex", align === "center" && "justify-between")}>
<div className={cx(align === "center" && "flex flex-1 justify-start")}>
<Button isDisabled={page === 1} color="secondary" size="sm" onClick={() => onPageChange?.(Math.max(0, page - 1))}>
Previous
</Button>
</div>
<span
className={cx(
"text-sm font-medium text-fg-secondary",
align === "right" && "order-first mr-auto",
align === "left" && "order-last ml-auto",
)}
>
Page {page} of {total}
</span>
<div className={cx(align === "center" && "flex flex-1 justify-end")}>
<Button isDisabled={page === total} color="secondary" size="sm" onClick={() => onPageChange?.(Math.min(total, page + 1))}>
Next
</Button>
</div>
</nav>
</div>
);
};
interface PaginationButtonGroupProps extends Partial<Omit<PaginationRootProps, "children">> {
/** The alignment of the pagination. */
align?: "left" | "center" | "right";
}
export const PaginationButtonGroup = ({ align = "left", page = 1, total = 10, ...props }: PaginationButtonGroupProps) => {
const isDesktop = useBreakpoint("md");
return (
<div
className={cx(
"flex border-t border-secondary px-4 py-3 md:px-6 md:pt-3 md:pb-4",
align === "left" && "justify-start",
align === "center" && "justify-center",
align === "right" && "justify-end",
)}
>
<Pagination.Root {...props} page={page} total={total}>
<Pagination.Context>
{({ pages }) => (
<ButtonGroup size="md">
<Pagination.PrevTrigger asChild>
<ButtonGroupItem iconLeading={ArrowLeft}>{isDesktop ? "Previous" : undefined}</ButtonGroupItem>
</Pagination.PrevTrigger>
{pages.map((page, index) =>
page.type === "page" ? (
<Pagination.Item key={index} {...page} asChild>
<ButtonGroupItem isSelected={page.isCurrent} className="size-10 items-center justify-center">
{page.value}
</ButtonGroupItem>
</Pagination.Item>
) : (
<Pagination.Ellipsis key={index}>
<ButtonGroupItem className="pointer-events-none size-10 items-center justify-center rounded-none!">
&#8230;
</ButtonGroupItem>
</Pagination.Ellipsis>
),
)}
<Pagination.NextTrigger asChild>
<ButtonGroupItem iconTrailing={ArrowRight}>{isDesktop ? "Next" : undefined}</ButtonGroupItem>
</Pagination.NextTrigger>
</ButtonGroup>
)}
</Pagination.Context>
</Pagination.Root>
</div>
);
};

View File

@@ -0,0 +1,120 @@
import { type ComponentPropsWithRef, type ReactNode, type RefAttributes } from "react";
import type {
DialogProps as AriaDialogProps,
ModalOverlayProps as AriaModalOverlayProps,
ModalRenderProps as AriaModalRenderProps,
} from "react-aria-components";
import { Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Modal as AriaModal, ModalOverlay as AriaModalOverlay } from "react-aria-components";
import { CloseButton } from "@/components/base/buttons/close-button";
import { cx } from "@/utils/cx";
interface ModalOverlayProps extends AriaModalOverlayProps, RefAttributes<HTMLDivElement> {}
export const ModalOverlay = (props: ModalOverlayProps) => {
return (
<AriaModalOverlay
{...props}
className={(state) =>
cx(
"fixed inset-0 flex min-h-dvh w-full items-center justify-end bg-overlay/70 pl-6 outline-hidden ease-linear md:pl-10",
state.isEntering && "duration-300 animate-in fade-in",
state.isExiting && "duration-500 animate-out fade-out",
typeof props.className === "function" ? props.className(state) : props.className,
)
}
/>
);
};
ModalOverlay.displayName = "ModalOverlay";
interface ModalProps extends AriaModalOverlayProps, RefAttributes<HTMLDivElement> {}
export const Modal = (props: ModalProps) => (
<AriaModal
{...props}
className={(state) =>
cx(
"inset-y-0 right-0 h-full w-full max-w-100 shadow-xl transition",
state.isEntering && "duration-300 animate-in slide-in-from-right",
state.isExiting && "duration-500 animate-out slide-out-to-right",
typeof props.className === "function" ? props.className(state) : props.className,
)
}
/>
);
Modal.displayName = "Modal";
interface DialogProps extends AriaDialogProps, RefAttributes<HTMLElement> {}
export const Dialog = (props: DialogProps) => (
<AriaDialog
role="dialog"
{...props}
className={cx(
"relative flex size-full flex-col items-start gap-6 overflow-y-auto bg-primary ring-1 ring-secondary_alt outline-hidden",
props.className,
)}
/>
);
Dialog.displayName = "Dialog";
interface SlideoutMenuProps extends Omit<AriaModalOverlayProps, "children">, RefAttributes<HTMLDivElement> {
children: ReactNode | ((children: AriaModalRenderProps & { close: () => void }) => ReactNode);
dialogClassName?: string;
}
const Menu = ({ children, dialogClassName, ...props }: SlideoutMenuProps) => {
return (
<ModalOverlay {...props}>
<Modal className={(state) => cx(typeof props.className === "function" ? props.className(state) : props.className)}>
{(state) => (
<Dialog className={dialogClassName}>
{({ close }) => {
return typeof children === "function" ? children({ ...state, close }) : children;
}}
</Dialog>
)}
</Modal>
</ModalOverlay>
);
};
Menu.displayName = "SlideoutMenu";
const Content = ({ role = "main", ...props }: ComponentPropsWithRef<"div">) => {
return <div role={role} {...props} className={cx("flex size-full flex-col gap-6 overflow-y-auto overscroll-auto px-4 md:px-6", props.className)} />;
};
Content.displayName = "SlideoutContent";
interface SlideoutHeaderProps extends ComponentPropsWithRef<"header"> {
onClose?: () => void;
}
const Header = ({ className, children, onClose, ...props }: SlideoutHeaderProps) => {
return (
<header {...props} className={cx("relative z-1 w-full px-4 pt-6 md:px-6", className)}>
{children}
<CloseButton size="md" className="absolute top-3 right-3 shrink-0" onClick={onClose} />
</header>
);
};
Header.displayName = "SlideoutHeader";
const Footer = (props: ComponentPropsWithRef<"footer">) => {
return <footer {...props} className={cx("w-full p-4 shadow-[inset_0px_1px_0px_0px] shadow-border-secondary md:px-6", props.className)} />;
};
Footer.displayName = "SlideoutFooter";
const SlideoutMenu = Menu as typeof Menu & {
Trigger: typeof AriaDialogTrigger;
Content: typeof Content;
Header: typeof Header;
Footer: typeof Footer;
};
SlideoutMenu.displayName = "SlideoutMenu";
SlideoutMenu.Trigger = AriaDialogTrigger;
SlideoutMenu.Content = Content;
SlideoutMenu.Header = Header;
SlideoutMenu.Footer = Footer;
export { SlideoutMenu };

View File

@@ -0,0 +1,298 @@
import type { ComponentPropsWithRef, HTMLAttributes, ReactNode, Ref, TdHTMLAttributes, ThHTMLAttributes } from "react";
import { createContext, isValidElement, useContext } from "react";
import { ArrowDown, ChevronSelectorVertical, Copy01, Edit01, HelpCircle, Trash01 } from "@untitledui/icons";
import type {
CellProps as AriaCellProps,
ColumnProps as AriaColumnProps,
RowProps as AriaRowProps,
TableHeaderProps as AriaTableHeaderProps,
TableProps as AriaTableProps,
} from "react-aria-components";
import {
Cell as AriaCell,
Collection as AriaCollection,
Column as AriaColumn,
Group as AriaGroup,
Row as AriaRow,
Table as AriaTable,
TableBody as AriaTableBody,
TableHeader as AriaTableHeader,
useTableOptions,
} from "react-aria-components";
import { Badge } from "@/components/base/badges/badges";
import { Checkbox } from "@/components/base/checkbox/checkbox";
import { Dropdown } from "@/components/base/dropdown/dropdown";
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
import { cx } from "@/utils/cx";
export const TableRowActionsDropdown = () => (
<Dropdown.Root>
<Dropdown.DotsButton />
<Dropdown.Popover className="w-min">
<Dropdown.Menu>
<Dropdown.Item icon={Edit01}>
<span className="pr-4">Edit</span>
</Dropdown.Item>
<Dropdown.Item icon={Copy01}>
<span className="pr-4">Copy link</span>
</Dropdown.Item>
<Dropdown.Item icon={Trash01}>
<span className="pr-4">Delete</span>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown.Root>
);
const TableContext = createContext<{ size: "sm" | "md" }>({ size: "md" });
const TableCardRoot = ({ children, className, size = "md", ...props }: HTMLAttributes<HTMLDivElement> & { size?: "sm" | "md" }) => {
return (
<TableContext.Provider value={{ size }}>
<div {...props} className={cx("overflow-hidden rounded-xl bg-primary shadow-xs ring-1 ring-secondary", className)}>
{children}
</div>
</TableContext.Provider>
);
};
interface TableCardHeaderProps {
/** The title of the table card header. */
title: string;
/** The badge displayed next to the title. */
badge?: ReactNode;
/** The description of the table card header. */
description?: string;
/** The content displayed after the title and badge. */
contentTrailing?: ReactNode;
/** The class name of the table card header. */
className?: string;
}
const TableCardHeader = ({ title, badge, description, contentTrailing, className }: TableCardHeaderProps) => {
const { size } = useContext(TableContext);
return (
<div
className={cx(
"relative flex flex-col items-start gap-4 border-b border-secondary bg-primary px-4 md:flex-row",
size === "sm" ? "py-4 md:px-5" : "py-5 md:px-6",
className,
)}
>
<div className="flex flex-1 flex-col gap-0.5">
<div className="flex items-center gap-2">
<h2 className={cx("font-semibold text-primary", size === "sm" ? "text-md" : "text-lg")}>{title}</h2>
{badge ? (
isValidElement(badge) ? (
badge
) : (
<Badge color="brand" size="sm">
{badge}
</Badge>
)
) : null}
</div>
{description && <p className="text-sm text-tertiary">{description}</p>}
</div>
{contentTrailing}
</div>
);
};
interface TableRootProps extends AriaTableProps, Omit<ComponentPropsWithRef<"table">, "className" | "slot" | "style"> {
size?: "sm" | "md";
}
const TableRoot = ({ className, size = "md", ...props }: TableRootProps) => {
const context = useContext(TableContext);
return (
<TableContext.Provider value={{ size: context?.size ?? size }}>
<div className="overflow-x-auto">
<AriaTable className={(state) => cx("w-full overflow-x-hidden", typeof className === "function" ? className(state) : className)} {...props} />
</div>
</TableContext.Provider>
);
};
TableRoot.displayName = "Table";
interface TableHeaderProps<T extends object>
extends AriaTableHeaderProps<T>,
Omit<ComponentPropsWithRef<"thead">, "children" | "className" | "slot" | "style"> {
bordered?: boolean;
}
const TableHeader = <T extends object>({ columns, children, bordered = true, className, ...props }: TableHeaderProps<T>) => {
const { size } = useContext(TableContext);
const { selectionBehavior, selectionMode } = useTableOptions();
return (
<AriaTableHeader
{...props}
className={(state) =>
cx(
"relative bg-secondary",
size === "sm" ? "h-9" : "h-11",
// Row border—using an "after" pseudo-element to avoid the border taking up space.
bordered &&
"[&>tr>th]:after:pointer-events-none [&>tr>th]:after:absolute [&>tr>th]:after:inset-x-0 [&>tr>th]:after:bottom-0 [&>tr>th]:after:h-px [&>tr>th]:after:bg-border-secondary [&>tr>th]:focus-visible:after:bg-transparent",
typeof className === "function" ? className(state) : className,
)
}
>
{selectionBehavior === "toggle" && (
<AriaColumn className={cx("relative py-2 pr-0 pl-4", size === "sm" ? "w-9 md:pl-5" : "w-11 md:pl-6")}>
{selectionMode === "multiple" && (
<div className="flex items-start">
<Checkbox slot="selection" size={size} />
</div>
)}
</AriaColumn>
)}
<AriaCollection items={columns}>{children}</AriaCollection>
</AriaTableHeader>
);
};
TableHeader.displayName = "TableHeader";
interface TableHeadProps extends AriaColumnProps, Omit<ThHTMLAttributes<HTMLTableCellElement>, "children" | "className" | "style" | "id"> {
label?: string;
tooltip?: string;
}
const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadProps) => {
const { selectionBehavior } = useTableOptions();
return (
<AriaColumn
{...props}
className={(state) =>
cx(
"relative p-0 px-6 py-2 outline-hidden focus-visible:z-1 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-bg-primary focus-visible:ring-inset",
selectionBehavior === "toggle" && "nth-2:pl-3",
state.allowsSorting && "cursor-pointer",
typeof className === "function" ? className(state) : className,
)
}
>
{(state) => (
<AriaGroup className="flex items-center gap-1">
<div className="flex items-center gap-1">
{label && <span className="text-xs font-semibold whitespace-nowrap text-quaternary">{label}</span>}
{typeof children === "function" ? children(state) : children}
</div>
{tooltip && (
<Tooltip title={tooltip} placement="top">
<TooltipTrigger className="cursor-pointer text-fg-quaternary transition duration-100 ease-linear hover:text-fg-quaternary_hover focus:text-fg-quaternary_hover">
<HelpCircle className="size-4" />
</TooltipTrigger>
</Tooltip>
)}
{state.allowsSorting &&
(state.sortDirection ? (
<ArrowDown className={cx("size-3 stroke-[3px] text-fg-quaternary", state.sortDirection === "ascending" && "rotate-180")} />
) : (
<ChevronSelectorVertical size={12} strokeWidth={3} className="text-fg-quaternary" />
))}
</AriaGroup>
)}
</AriaColumn>
);
};
TableHead.displayName = "TableHead";
interface TableRowProps<T extends object>
extends AriaRowProps<T>,
Omit<ComponentPropsWithRef<"tr">, "children" | "className" | "onClick" | "slot" | "style" | "id"> {
highlightSelectedRow?: boolean;
}
const TableRow = <T extends object>({ columns, children, className, highlightSelectedRow = true, ...props }: TableRowProps<T>) => {
const { size } = useContext(TableContext);
const { selectionBehavior } = useTableOptions();
return (
<AriaRow
{...props}
className={(state) =>
cx(
"relative outline-focus-ring transition-colors after:pointer-events-none hover:bg-secondary focus-visible:outline-2 focus-visible:-outline-offset-2",
size === "sm" ? "h-14" : "h-18",
highlightSelectedRow && "selected:bg-secondary",
// Row border—using an "after" pseudo-element to avoid the border taking up space.
"[&>td]:after:absolute [&>td]:after:inset-x-0 [&>td]:after:bottom-0 [&>td]:after:h-px [&>td]:after:w-full [&>td]:after:bg-border-secondary last:[&>td]:after:hidden [&>td]:focus-visible:after:opacity-0 focus-visible:[&>td]:after:opacity-0",
typeof className === "function" ? className(state) : className,
)
}
>
{selectionBehavior === "toggle" && (
<AriaCell className={cx("relative py-2 pr-0 pl-4", size === "sm" ? "md:pl-5" : "md:pl-6")}>
<div className="flex items-end">
<Checkbox slot="selection" size={size} />
</div>
</AriaCell>
)}
<AriaCollection items={columns}>{children}</AriaCollection>
</AriaRow>
);
};
TableRow.displayName = "TableRow";
interface TableCellProps extends AriaCellProps, Omit<TdHTMLAttributes<HTMLTableCellElement>, "children" | "className" | "style" | "id"> {
ref?: Ref<HTMLTableCellElement>;
}
const TableCell = ({ className, children, ...props }: TableCellProps) => {
const { size } = useContext(TableContext);
const { selectionBehavior } = useTableOptions();
return (
<AriaCell
{...props}
className={(state) =>
cx(
"relative text-sm text-tertiary outline-focus-ring focus-visible:z-1 focus-visible:outline-2 focus-visible:-outline-offset-2",
size === "sm" && "px-5 py-3",
size === "md" && "px-6 py-4",
selectionBehavior === "toggle" && "nth-2:pl-3",
typeof className === "function" ? className(state) : className,
)
}
>
{children}
</AriaCell>
);
};
TableCell.displayName = "TableCell";
const TableCard = {
Root: TableCardRoot,
Header: TableCardHeader,
};
const Table = TableRoot as typeof TableRoot & {
Body: typeof AriaTableBody;
Cell: typeof TableCell;
Head: typeof TableHead;
Header: typeof TableHeader;
Row: typeof TableRow;
};
Table.Body = AriaTableBody;
Table.Cell = TableCell;
Table.Head = TableHead;
Table.Header = TableHeader;
Table.Row = TableRow;
export { Table, TableCard };

View File

@@ -0,0 +1,223 @@
import type { ComponentPropsWithRef, ReactNode } from "react";
import { Fragment, createContext, useContext } from "react";
import type { TabListProps as AriaTabListProps, TabProps as AriaTabProps, TabRenderProps as AriaTabRenderProps } from "react-aria-components";
import { Tab as AriaTab, TabList as AriaTabList, TabPanel as AriaTabPanel, Tabs as AriaTabs, TabsContext, useSlottedContext } from "react-aria-components";
import type { BadgeColors } from "@/components/base/badges/badge-types";
import { Badge } from "@/components/base/badges/badges";
import { cx } from "@/utils/cx";
type Orientation = "horizontal" | "vertical";
// Types for different orientations
type HorizontalTypes = "button-brand" | "button-gray" | "button-border" | "button-minimal" | "underline";
type VerticalTypes = "button-brand" | "button-gray" | "button-border" | "button-minimal" | "line";
type TabTypeColors<T> = T extends "horizontal" ? HorizontalTypes : VerticalTypes;
// Styles for different types of tab
const getTabStyles = ({ isFocusVisible, isSelected, isHovered }: AriaTabRenderProps) => ({
"button-brand": cx(
"outline-focus-ring",
isFocusVisible && "outline-2 -outline-offset-2",
(isSelected || isHovered) && "bg-brand-primary_alt text-brand-secondary",
),
"button-gray": cx(
"outline-focus-ring",
isHovered && "bg-primary_hover text-secondary",
isFocusVisible && "outline-2 -outline-offset-2",
isSelected && "bg-active text-secondary",
),
"button-border": cx(
"outline-focus-ring",
(isSelected || isHovered) && "bg-primary_alt text-secondary shadow-sm",
isFocusVisible && "outline-2 -outline-offset-2",
),
"button-minimal": cx(
"rounded-lg outline-focus-ring",
isHovered && "text-secondary",
isFocusVisible && "outline-2 -outline-offset-2",
isSelected && "bg-primary_alt text-secondary shadow-xs ring-1 ring-primary ring-inset",
),
underline: cx(
"rounded-none border-b-2 border-transparent outline-focus-ring",
(isSelected || isHovered) && "border-fg-brand-primary_alt text-brand-secondary",
isFocusVisible && "outline-2 -outline-offset-2",
),
line: cx(
"rounded-none border-l-2 border-transparent outline-focus-ring",
(isSelected || isHovered) && "border-fg-brand-primary_alt text-brand-secondary",
isFocusVisible && "outline-2 -outline-offset-2",
),
});
const sizes = {
sm: {
"button-brand": "text-sm font-semibold py-2 px-3",
"button-gray": "text-sm font-semibold py-2 px-3",
"button-border": "text-sm font-semibold py-2 px-3",
"button-minimal": "text-sm font-semibold py-2 px-3",
underline: "text-sm font-semibold px-1 pb-2.5 pt-0",
line: "text-sm font-semibold pl-2.5 pr-3 py-0.5",
},
md: {
"button-brand": "text-md font-semibold py-2.5 px-3",
"button-gray": "text-md font-semibold py-2.5 px-3",
"button-border": "text-md font-semibold py-2.5 px-3",
"button-minimal": "text-md font-semibold py-2.5 px-3",
underline: "text-md font-semibold px-1 pb-2.5 pt-0",
line: "text-md font-semibold pr-3.5 pl-3 py-1",
},
};
// Styles for different types of horizontal tabs
const getHorizontalStyles = ({ size, fullWidth }: { size?: "sm" | "md"; fullWidth?: boolean }) => ({
"button-brand": "gap-1",
"button-gray": "gap-1",
"button-border": cx("gap-1 rounded-[10px] bg-secondary_alt p-1 ring-1 ring-secondary ring-inset", size === "md" && "rounded-xl p-1.5"),
"button-minimal": "gap-0.5 rounded-lg bg-secondary_alt ring-1 ring-inset ring-secondary",
underline: cx("gap-3", fullWidth && "w-full gap-4"),
line: "gap-2",
});
const getColorStyles = ({ isSelected, isHovered }: Partial<AriaTabRenderProps>) => ({
"button-brand": isSelected || isHovered ? "brand" : "gray",
"button-gray": "gray",
"button-border": "gray",
"button-minimal": "gray",
underline: isSelected || isHovered ? "brand" : "gray",
line: isSelected || isHovered ? "brand" : "gray",
});
interface TabListComponentProps<T extends object, K extends Orientation> extends AriaTabListProps<T> {
/** The size of the tab list. */
size?: keyof typeof sizes;
/** The type of the tab list. */
type?: TabTypeColors<K>;
/** The orientation of the tab list. */
orientation?: K;
/** The items of the tab list. */
items: T[];
/** Whether the tab list is full width. */
fullWidth?: boolean;
}
const TabListContext = createContext<Omit<TabListComponentProps<TabComponentProps, Orientation>, "items">>({
size: "sm",
type: "button-brand",
});
export const TabList = <T extends Orientation>({
size = "sm",
type = "button-brand",
orientation: orientationProp,
fullWidth,
className,
children,
...otherProps
}: TabListComponentProps<TabComponentProps, T>) => {
const context = useSlottedContext(TabsContext);
const orientation = orientationProp ?? context?.orientation ?? "horizontal";
return (
<TabListContext.Provider value={{ size, type, orientation, fullWidth }}>
<AriaTabList
{...otherProps}
className={(state) =>
cx(
"group flex",
getHorizontalStyles({
size,
fullWidth,
})[type as HorizontalTypes],
orientation === "vertical" && "w-max flex-col",
// Only horizontal tabs with underline type have bottom border
orientation === "horizontal" &&
type === "underline" &&
"relative before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-border-secondary",
typeof className === "function" ? className(state) : className,
)
}
>
{children ?? ((item) => <Tab {...item}>{item.children}</Tab>)}
</AriaTabList>
</TabListContext.Provider>
);
};
export const TabPanel = (props: ComponentPropsWithRef<typeof AriaTabPanel>) => {
return (
<AriaTabPanel
{...props}
className={(state) =>
cx(
"outline-focus-ring focus-visible:outline-2 focus-visible:outline-offset-2",
typeof props.className === "function" ? props.className(state) : props.className,
)
}
/>
);
};
interface TabComponentProps extends AriaTabProps {
/** The label of the tab. */
label?: ReactNode;
/** The children of the tab. */
children?: ReactNode | ((props: AriaTabRenderProps) => ReactNode);
/** The badge displayed next to the label. */
badge?: number | string;
}
export const Tab = (props: TabComponentProps) => {
const { label, children, badge, ...otherProps } = props;
const { size = "sm", type = "button-brand", fullWidth } = useContext(TabListContext);
return (
<AriaTab
{...otherProps}
className={(prop) =>
cx(
"z-10 flex h-max cursor-pointer items-center justify-center gap-2 rounded-md whitespace-nowrap text-quaternary transition duration-100 ease-linear",
"group-orientation-vertical:justify-start",
fullWidth && "w-full flex-1",
sizes[size][type],
getTabStyles(prop)[type],
typeof props.className === "function" ? props.className(prop) : props.className,
)
}
>
{(state) => (
<Fragment>
{typeof children === "function" ? children(state) : children || label}
{badge && (
<Badge
size={size}
type="pill-color"
color={getColorStyles(state)[type] as BadgeColors}
className={cx("hidden transition-inherit-all md:flex", size === "sm" && "-my-px")}
>
{badge}
</Badge>
)}
</Fragment>
)}
</AriaTab>
);
};
export const Tabs = ({ className, ...props }: ComponentPropsWithRef<typeof AriaTabs>) => {
return (
<AriaTabs
keyboardActivation="manual"
{...props}
className={(state) => cx("flex w-full flex-col", typeof className === "function" ? className(state) : className)}
/>
);
};
Tabs.Panel = TabPanel;
Tabs.List = TabList;
Tabs.Item = Tab;