Yugen UI

Định vị (Positioning)

Popover, Menu, Tooltip, HoverCard và listbox dùng hệ định vị nổi của Radix: chọn cạnh, căn lề, khoảng cách — và tự lật khi thiếu chỗ.

Các prop định vị

PopoverSurface, MenuPopover, MenubarContent, TooltipContent, HoverCardContent chuyển thẳng các prop này cho Radix Popper (ContextMenu mở tại con trỏ nên chỉ nhận nhóm prop va chạm).

Prop · KiểuMặc địnhMô tả
side"top" | "right" | "bottom" | "left"Mặc định: "bottom" (Tooltip: "top")Cạnh của trigger mà bề mặt bám vào. Là hướng vật lý: left / right không tự đảo khi RTL.
align"start" | "center" | "end"Mặc định: PopoverSurface "center" · MenuPopover "start"Căn theo trục còn lại. start / end theo hướng chữ — Yugen gắn dir của provider lên bề mặt nên RTL tự đảo.
sideOffsetnumberMặc định: Popover 6 · HoverCard 6 · Menu 4 · Tooltip 4Khoảng cách (px) giữa trigger và bề mặt.
alignOffsetnumberMặc định: 0Dịch theo trục căn lề (dương = về phía end).
avoidCollisionsbooleanMặc định: trueTự lật sang cạnh đối diện và trượt dọc trục để không tràn khỏi khung nhìn.
collisionPaddingnumber | Partial<Record<Side, number>>Mặc định: 0Khoảng đệm với mép vùng va chạm (ví dụ chừa chỗ cho header sticky).
collisionBoundaryElement | Element[]Mặc định: viewportVùng dùng để tính va chạm thay cho khung nhìn.
sticky"partial" | "always"Mặc định: "partial"Giữ bề mặt trong khung nhìn khi trigger cuộn ra một phần / hoàn toàn.
hideWhenDetachedbooleanMặc định: falseẨn bề mặt khi trigger bị che hoàn toàn (ví dụ cuộn ra khỏi vùng cuộn).

Sau khi định vị, Radix ghi data-side và data-align thực tế (kể cả khi đã lật) lên bề mặt, và cung cấp biến CSS --radix-popover-content-available-height, --radix-popover-trigger-width… (tiền tố đổi theo component: tooltip, dropdown-menu, select, hover-card).

Thử side & align

Chọn giá trị rồi mở popover. Thử side="right" trên màn hình hẹp để thấy bề mặt tự lật.

<Popover>
  <PopoverTrigger asChild>
    <Button appearance="primary">Mở popover</Button>
  </PopoverTrigger>
  <PopoverSurface side="right" align="start" sideOffset={16} withArrow>
    Nội dung
  </PopoverSurface>
</Popover>

{/* Tooltip mặc định side="top" */}
<SimpleTooltip content="Sao chép" side="bottom">
  <Button icon={<Copy20Regular />} aria-label="Sao chép" />
</SimpleTooltip>

Va chạm & tự lật

Mặc định bề mặt không bao giờ tràn khỏi khung nhìn: thiếu chỗ ở cạnh đã chọn thì lật sang cạnh đối diện, thiếu chỗ theo trục căn thì trượt vào trong.

{/* Chừa chỗ cho thanh trên cùng cao 74px và lề 16px ở các cạnh còn lại */}
<PopoverSurface
  side="bottom"
  align="start"
  collisionPadding={{ top: 80, right: 16, bottom: 16, left: 16 }}
  className="max-h-(--radix-popover-content-available-height) overflow-y-auto data-[side=top]:origin-bottom"
>
  …
</PopoverSurface>

{/* Bám cố định theo phía đã chọn, chấp nhận bị cắt (hiếm khi cần) */}
<MenuPopover avoidCollisions={false}>…</MenuPopover>
  • Header sticky không được Radix biết tới — dùng collisionPadding ở cạnh tương ứng để bề mặt không chui dưới header.
  • Danh sách dài: giới hạn chiều cao bằng biến --radix-*-content-available-height và cho cuộn, thay vì để bề mặt cao quá khung nhìn (Dropdown đã làm sẵn).
  • Animation theo hướng xuất hiện: style bằng data-[side=top]:… vì cạnh thực tế có thể khác cạnh bạn chọn.
Thử ngay:cuộn để nút “Mở popover” ở trên sát mép dưới màn hình rồi mở với side="bottom" — bề mặt sẽ hiện phía trên.

Portal & theme

Mọi bề mặt nổi render qua portal vào <body> để thoát overflow: hidden và z-index của cha. Chúng vẫn tự nhận theme, brand và hướng chữ của YugenProvider lồng gần nhất.

Vùng này là YugenProvider theme="dark" brand="web".

<YugenProvider theme="dark" brand="web" className="p-4">
  <Popover>
    <PopoverTrigger asChild>
      <Button appearance="primary">Mở popover</Button>
    </PopoverTrigger>
    {/* Tự nhận theme tối + brand Web của provider lồng, dù render vào <body> */}
    <PopoverSurface withArrow>Nền tối, brand Web.</PopoverSurface>
  </Popover>
</YugenProvider>

// Tự viết bề mặt portal mới? Gắn props của provider gần nhất:
import { usePortalThemeProps } from "@/components/ui";

function MyFloatingSurface(props) {
  const portalTheme = usePortalThemeProps(); // {} khi ở provider gốc
  return <SomePrimitive.Portal><SomePrimitive.Content {...portalTheme} {...props} /></SomePrimitive.Portal>;
}
  • Theme cho cả ứng dụng: luôn dùng <YugenProvider applyToDocument> ở gốc — attribute nằm trên <html> nên mọi portal tự đúng.
  • Theme lồng: mọi bề mặt nổi của Yugen (PopoverSurface, MenuPopover, TooltipContent, HoverCardContent, DialogSurface, DrawerSurface, ContextMenu, Menubar, listbox Dropdown / Combobox / TagPicker) tự gắn data-theme / data-brand /dir của provider lồng qua hook usePortalThemeProps(). Bề mặt tự viết thì gọi hook này.
  • Hướng chữ không cần xử lý: các bề mặt nổi (PopoverSurface, TooltipContent, HoverCardContent, DialogSurface, DrawerSurface, menu và listbox) tự gắn dir của provider gần nhất, vì context React (kể cả DirectionProvider) đi xuyên qua portal.

Khoá cuộn

Menu, ContextMenu và Dropdown khoá cuộn trang khi mở: lăn chuột và vuốt bên ngoài bị chặn, thanh cuộn của trang ẩn đi và body được bù lề để nội dung không xê dịch. App chọn giữ hay ẩn thanh cuộn.

Nhấn chuột phải vào đây
Bề mặtKhoá cuộnTắt được
Menu (cả MenuButton, SplitButton, mục thừa của Breadcrumb, DataGridColumnPicker)modal mặc định truemodal={false}
ContextMenumodal mặc định truemodal={false}
DropdownLuôn khoáKhông; dùng scrollLock để giữ thanh cuộn
MenubarKhông (menu của Menubar không modal)—
Popover và component dựa trên Popover (Combobox, TagPicker, TimePicker…)Không, trừ khi modal—
Dialog, Drawer có lớp phủ, Sheet, TourCóĐúng ý đồ: nội dung phía sau không dùng được
// Cả app: giữ thanh cuộn khi Menu, ContextMenu, Dropdown mở
<YugenProvider applyToDocument scrollLock="keep-scrollbar">
  <App />
</YugenProvider>

// Không dùng provider: đặt attribute trên <html>
<html data-yugen-scroll-lock="keep-scrollbar">

// Một menu không khoá cuộn
<Menu modal={false}>…</Menu>
  • scrollLock="keep-scrollbar" trên provider gốc ghi data-yugen-scroll-lock lên <html>: thanh cuộn vẫn hiện và không có gì xê dịch, nhưng lăn chuột / vuốt bên ngoài bề mặt vẫn bị chặn. App không dùng provider thì tự đặt attribute này. Mặc định hiện tại là "hide-scrollbar"; bản 1.0 sẽ đổi sang "keep-scrollbar".
  • modal={false} trên Menu hoặc ContextMenu: trang vẫn cuộn được, bấm ra ngoài tới trang ngay, và phần còn lại của trang không bị ẩn khỏi trình đọc màn hình. Hợp với menu mở từ thanh công cụ trong vùng nội dung dài.
  • Phần tử position: fixed sát mép phải (header, nút nổi) xê dịch theo bề rộng thanh cuộn khi thanh cuộn ẩn, trừ khi có class right-scroll-bar-position (hoặc width-before-scroll-bar cho phần tử rộng hết trang). Với keep-scrollbar thì không cần.

Thang z-index

Chỉ dùng vài bậc cố định; các bề mặt cùng bậc xếp chồng theo thứ tự mở (portal mở sau nằm trên).

BậcDùng cho
z-30Thanh trên cùng sticky (TopBar của site docs). Nội dung sticky của ứng dụng không vượt bậc này.
z-40Lớp phủ của sidebar trên mobile (site docs).
z-50Mọi overlay: lớp phủ + bề mặt Dialog / Drawer, Popover, Menu, Tooltip, HoverCard, listbox Dropdown / Combobox; sidebar mobile.
z-100Vùng Toast — luôn nằm trên cả Dialog để thông báo không bị che.
Nên
  • Để bề mặt nổi trong portal của component; dùng collisionPadding cho header sticky.
  • Chọn side theo nội dung: Tooltip ở trên, Menu và listbox ở dưới, panel phụ ở cạnh.
  • Giữ z-index trong thang 30 / 40 / 50 / 100.
Không nên
  • Không đặt z-index tuỳ ý (z-[…] cỡ hàng nghìn) để “thắng” overlay.
  • Không tắt avoidCollisions chỉ để bề mặt luôn ở một phía.
  • Không tự viết Portal mà quên usePortalThemeProps() — bề mặt sẽ lấy theme của <html>.