Files
temetro/frontend/components/ui/menu.tsx
T
Khalid Abdi 4d6a5dc008 Migrate UI to COSS components + COSS neutral theme; add i18next
Components:
- Replace components/ui primitives with COSS equivalents from the @coss/* shadcn
  registry. Add menu/preview-card/group; remove the superseded dropdown-menu,
  hover-card, button-group; keep carousel (no COSS equivalent).
- Migrate live call sites to canonical COSS APIs preserving layout/behavior:
  sidebar menus (nav-user, team-switcher, nav-notifications), chat-input radio
  menu, patient dialogs/cards, auth forms (FieldGroup -> flex column), settings.
- ai-elements: migrate the live conversation/message with behavior parity
  (Tooltip via render, Group/GroupText); repoint dormant files (hover-card ->
  preview-card, dropdown-menu -> menu, button-group -> group, InputGroupButton
  -> Button). Residual pre-existing Base UI type drift stays behind
  ignoreBuildErrors.

Theme:
- Adopt COSS default neutral tokens in globals.css with light + dark palettes.
- Add next-themes (defaultTheme=dark, enableSystem) and drop the forced dark
  class. Align font variables to the COSS contract (--font-sans/-heading/-mono).
  Make scrollbars theme-aware.

i18n:
- Add i18next + react-i18next (lib/i18n/config.ts, locales/en/translation.json,
  components/i18n-provider.tsx mounted in layout). Convert auth forms, sidebar
  nav, and settings tabs to useTranslation() as the reference pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:39:20 +03:00

328 lines
11 KiB
TypeScript

"use client";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { ChevronRightIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
export const MenuCreateHandle: typeof MenuPrimitive.createHandle =
MenuPrimitive.createHandle;
export const Menu: typeof MenuPrimitive.Root = MenuPrimitive.Root;
export const MenuPortal: typeof MenuPrimitive.Portal = MenuPrimitive.Portal;
export function MenuTrigger({
className,
children,
...props
}: MenuPrimitive.Trigger.Props): React.ReactElement {
return (
<MenuPrimitive.Trigger
className={className}
data-slot="menu-trigger"
{...props}
>
{children}
</MenuPrimitive.Trigger>
);
}
export function MenuPopup({
children,
className,
sideOffset = 4,
align = "center",
alignOffset,
side = "bottom",
anchor,
portalProps,
...props
}: MenuPrimitive.Popup.Props & {
align?: MenuPrimitive.Positioner.Props["align"];
sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
side?: MenuPrimitive.Positioner.Props["side"];
anchor?: MenuPrimitive.Positioner.Props["anchor"];
portalProps?: MenuPrimitive.Portal.Props;
}): React.ReactElement {
return (
<MenuPortal {...portalProps}>
<MenuPrimitive.Positioner
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="z-50"
data-slot="menu-positioner"
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
className={cn(
"relative flex not-[class*='w-']:min-w-32 origin-(--transform-origin) rounded-lg border bg-popover not-dark:bg-clip-padding shadow-lg/5 outline-none before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] focus:outline-none dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
className,
)}
data-slot="menu-popup"
{...props}
>
<div className="max-h-(--available-height) w-full overflow-y-auto p-1">
{children}
</div>
</MenuPrimitive.Popup>
</MenuPrimitive.Positioner>
</MenuPortal>
);
}
export function MenuGroup(
props: MenuPrimitive.Group.Props,
): React.ReactElement {
return <MenuPrimitive.Group data-slot="menu-group" {...props} />;
}
export function MenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean;
variant?: "default" | "destructive";
}): React.ReactElement {
return (
<MenuPrimitive.Item
className={cn(
"flex min-h-8 cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-inset:ps-8 data-[variant=destructive]:text-destructive-foreground data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&>svg:not([class*='opacity-'])]:opacity-80 [&>svg:not([class*='size-'])]:size-4.5 sm:[&>svg:not([class*='size-'])]:size-4 [&>svg]:pointer-events-none [&>svg]:-mx-0.5 [&>svg]:shrink-0",
className,
)}
data-inset={inset}
data-slot="menu-item"
data-variant={variant}
{...props}
/>
);
}
export function MenuCheckboxItem({
className,
children,
checked,
variant = "default",
...props
}: MenuPrimitive.CheckboxItem.Props & {
variant?: "default" | "switch";
}): React.ReactElement {
return (
<MenuPrimitive.CheckboxItem
checked={checked}
className={cn(
"grid min-h-8 in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] cursor-default items-center gap-2 rounded-sm py-1 ps-2 text-base text-foreground outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variant === "switch"
? "grid-cols-[1fr_auto] gap-4 pe-1.5"
: "grid-cols-[.75rem_1fr] pe-4",
className,
)}
data-slot="menu-checkbox-item"
{...props}
>
{variant === "switch" ? (
<>
<span className="col-start-1">{children}</span>
<MenuPrimitive.CheckboxItemIndicator
className="inset-shadow-[0_1px_--theme(--color-black/4%)] inline-flex h-[calc(var(--thumb-size)+2px)] w-[calc(var(--thumb-size)*2-2px)] shrink-0 items-center rounded-full p-px outline-none transition-[background-color,box-shadow] duration-200 [--thumb-size:--spacing(4)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-checked:bg-primary data-unchecked:bg-input data-disabled:opacity-64 sm:[--thumb-size:--spacing(3)]"
keepMounted
>
<span className="pointer-events-none block aspect-square h-full in-[[data-slot=menu-checkbox-item][data-checked]]:origin-[var(--thumb-size)_50%] origin-left in-[[data-slot=menu-checkbox-item][data-checked]]:translate-x-[calc(var(--thumb-size)-4px)] in-[[data-slot=menu-checkbox-item]:active]:not-data-disabled:scale-x-110 in-[[data-slot=menu-checkbox-item]:active]:rounded-[var(--thumb-size)/calc(var(--thumb-size)*1.10)] rounded-(--thumb-size) bg-background shadow-sm/5 will-change-transform [transition:translate_.15s,border-radius_.15s,scale_.1s_.1s,transform-origin_.15s]" />
</MenuPrimitive.CheckboxItemIndicator>
</>
) : (
<>
<MenuPrimitive.CheckboxItemIndicator className="col-start-1 -ms-0.5">
<svg
aria-hidden="true"
fill="none"
height="24"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M5.252 12.7 10.2 18.63 18.748 5.37" />
</svg>
</MenuPrimitive.CheckboxItemIndicator>
<span className="col-start-2">{children}</span>
</>
)}
</MenuPrimitive.CheckboxItem>
);
}
export function MenuRadioGroup(
props: MenuPrimitive.RadioGroup.Props,
): React.ReactElement {
return <MenuPrimitive.RadioGroup data-slot="menu-radio-group" {...props} />;
}
export function MenuRadioItem({
className,
children,
...props
}: MenuPrimitive.RadioItem.Props): React.ReactElement {
return (
<MenuPrimitive.RadioItem
className={cn(
"grid min-h-8 in-data-[side=none]:min-w-[calc(var(--anchor-width)+1.25rem)] cursor-default grid-cols-[.75rem_1fr] items-center gap-2 rounded-sm py-1 ps-2 pe-4 text-base text-foreground outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
data-slot="menu-radio-item"
{...props}
>
<MenuPrimitive.RadioItemIndicator className="col-start-1 -ms-0.5">
<svg
aria-hidden="true"
fill="none"
height="24"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M5.252 12.7 10.2 18.63 18.748 5.37" />
</svg>
</MenuPrimitive.RadioItemIndicator>
<span className="col-start-2">{children}</span>
</MenuPrimitive.RadioItem>
);
}
export function MenuGroupLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}): React.ReactElement {
return (
<MenuPrimitive.GroupLabel
className={cn(
"px-2 py-1.5 font-medium text-muted-foreground text-xs data-inset:ps-9 sm:data-inset:ps-8",
className,
)}
data-inset={inset}
data-slot="menu-label"
{...props}
/>
);
}
export function MenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props): React.ReactElement {
return (
<MenuPrimitive.Separator
className={cn("mx-2 my-1 h-px bg-border", className)}
data-slot="menu-separator"
{...props}
/>
);
}
export function MenuShortcut({
className,
...props
}: React.ComponentProps<"kbd">): React.ReactElement {
return (
<kbd
className={cn(
"ms-auto font-medium font-sans text-muted-foreground/72 text-xs tracking-widest",
className,
)}
data-slot="menu-shortcut"
{...props}
/>
);
}
export function MenuSub(
props: MenuPrimitive.SubmenuRoot.Props,
): React.ReactElement {
return <MenuPrimitive.SubmenuRoot data-slot="menu-sub" {...props} />;
}
export function MenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}): React.ReactElement {
return (
<MenuPrimitive.SubmenuTrigger
className={cn(
"flex min-h-8 items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-popup-open:bg-accent data-inset:ps-8 data-highlighted:text-accent-foreground data-popup-open:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&>svg:not(:last-child)]:-mx-0.5 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className,
)}
data-inset={inset}
data-slot="menu-sub-trigger"
{...props}
>
{children}
<ChevronRightIcon className="ms-auto -me-0.5 opacity-80" />
</MenuPrimitive.SubmenuTrigger>
);
}
export function MenuSubPopup({
className,
sideOffset = 0,
alignOffset,
align = "start",
...props
}: MenuPrimitive.Popup.Props & {
align?: MenuPrimitive.Positioner.Props["align"];
sideOffset?: MenuPrimitive.Positioner.Props["sideOffset"];
alignOffset?: MenuPrimitive.Positioner.Props["alignOffset"];
}): React.ReactElement {
const defaultAlignOffset = align !== "center" ? -5 : undefined;
return (
<MenuPopup
align={align}
alignOffset={alignOffset ?? defaultAlignOffset}
className={className}
data-slot="menu-sub-content"
side="inline-end"
sideOffset={sideOffset}
{...props}
/>
);
}
export {
MenuPrimitive,
MenuCreateHandle as DropdownMenuCreateHandle,
Menu as DropdownMenu,
MenuPortal as DropdownMenuPortal,
MenuTrigger as DropdownMenuTrigger,
MenuPopup as DropdownMenuContent,
MenuGroup as DropdownMenuGroup,
MenuItem as DropdownMenuItem,
MenuCheckboxItem as DropdownMenuCheckboxItem,
MenuRadioGroup as DropdownMenuRadioGroup,
MenuRadioItem as DropdownMenuRadioItem,
MenuGroupLabel as DropdownMenuLabel,
MenuSeparator as DropdownMenuSeparator,
MenuShortcut as DropdownMenuShortcut,
MenuSub as DropdownMenuSub,
MenuSubTrigger as DropdownMenuSubTrigger,
MenuSubPopup as DropdownMenuSubContent,
};