Tuỳ biến bằng slot & composition
Fluent v9 cho thay từng phần của component bằng slot; Yugen đạt cùng mục tiêu bằng asChild, prop nhận ReactNode, component ghép và render-prop.
Slot trong Yugen
Mỗi khái niệm slot của Fluent v9 có một cách tương ứng trong Yugen — đều là React thuần, không cần API riêng.
| Fluent v9 | Yugen UI | Ví dụ |
|---|---|---|
Slot root, prop as | asChild | Button, Link, BreadcrumbButton, NavItem; mọi *Trigger của Radix |
Slot icon | icon | Button, ToggleButton, Badge, MenuItem, TabsTrigger, AccordionHeader |
Slot contentBefore / contentAfter | Cùng tên | Input, SearchBox |
Slot media | media | ListItem, DropdownOption, TableCellLayout, TagPicker option |
Slot action / actions | Cùng tên | CardHeader action, MessageBar actions, ListItem actions, TreeItemLayout actions |
| Sub-component (CardHeader, DialogBody…) | Component ghép | Card*, Dialog*, Drawer*, Table*, Menu* |
| Slot nhận hàm render | Render-prop children | Field: (props) => <Control {...props} /> |
| className của slot con | rootClassName, listboxClassName… | Input, Slider, InfoLabel, Dropdown, Combobox, Select |
asChild — đổi phần tử gốc
asChild dùng Slot của Radix: component không render thẻ của nó mà gộp className, ref, sự kiện và aria-* vào đúng một phần tử con bạn truyền vào.
import { ArrowRight16Regular, Share20Regular } from "@fluentui/react-icons";
import { Button, Popover, PopoverSurface, PopoverTrigger } from "@/components/ui";
{/* Button render thành <a> nhưng giữ nguyên style, ref, sự kiện */}
<Button asChild appearance="primary">
<a href="#/concepts/theming">
Đọc về Theming
<ArrowRight16Regular className="rtl:-scale-x-100" />
</a>
</Button>
{/* Trigger của Radix gộp hành vi mở / aria-* vào Button của bạn */}
<Popover>
<PopoverTrigger asChild>
<Button icon={<Share20Regular />}>Chia sẻ</Button>
</PopoverTrigger>
<PopoverSurface>Liên kết đã được sao chép.</PopoverSurface>
</Popover>- Con phải là một phần tử duy nhất, forward ref và trải (spread) props xuống DOM.
- Với
<Button asChild>, propiconbị bỏ qua (Button chỉ render children) — đặt icon bên trong phần tử con như ví dụ trên. - Dùng asChild để bọc link của router:
<BreadcrumbButton asChild><RouterLink to="/">…</RouterLink></BreadcrumbButton>.
Prop nhận ReactNode: icon, contentBefore, media, actions
Thay vì slot object, các vùng phụ nhận thẳng ReactNode. Component lo căn chỉnh, khoảng cách và cỡ icon.
<Button icon={<Add20Regular />}>Tạo mới</Button>
<Button icon={<ArrowRight16Regular className="rtl:-scale-x-100" />} iconPosition="after">Tiếp tục</Button>
<Button appearance="subtle" icon={<MoreHorizontal20Regular />} aria-label="Thêm" />
<Input
aria-label="Tìm tệp"
placeholder="Tìm tệp"
contentBefore={<Search20Regular />}
contentAfter={<Kbd>/</Kbd>}
rootClassName="w-64"
/>
<Badge icon={<Document20Regular />}>12</Badge>
<Menu>
<MenuTrigger asChild>
<Button>Thao tác</Button>
</MenuTrigger>
<MenuPopover>
<MenuItem icon={<Edit20Regular />} secondaryContent="Ctrl+E">Sửa</MenuItem>
<MenuItem icon={<Copy20Regular />} secondaryContent="Ctrl+D">Nhân bản</MenuItem>
<MenuDivider />
<MenuItem icon={<Delete20Regular />}>Xoá</MenuItem>
</MenuPopover>
</Menu>- Phạm Thu HàĐã chỉnh sửa Kế hoạch quý 3
<List className="w-full max-w-md">
<ListItem
media={<Avatar name="Phạm Thu Hà" color="colorful" />}
title="Phạm Thu Hà"
description="Đã chỉnh sửa Kế hoạch quý 3"
actions={<Button size="small" appearance="subtle" icon={<MoreHorizontal20Regular />} aria-label="Thêm thao tác" />}
/>
</List>
<Dropdown placeholder="Chọn thư mục" aria-label="Thư mục">
<DropdownOption value="docs" media={<Folder20Regular />}>Tài liệu</DropdownOption>
<DropdownOption value="design" media={<Folder20Regular />}>Thiết kế</DropdownOption>
</Dropdown>Cỡ icon do component đặt bằng [&_svg]:size-* (small 16, medium 20, large 24), nên chỉ cần truyền icon Fluent cỡ 20 và để component co giãn. Icon chỉ có nghĩa trang trí không cần aria-label; nút chỉ có icon thì bắt buộc có.
Component ghép (compound)
Card, Dialog, Drawer, Table, Menu được chia thành nhiều phần nhỏ. Bỏ phần không cần, đổi thứ tự, hoặc chèn phần tử của bạn vào giữa.
import { Avatar, Badge, Button, Card, CardContent, CardFooter, CardHeader } from "@/components/ui";
<Card className="w-full max-w-sm">
<CardHeader
image={<Avatar name="Nhóm Sakura" color="brand" />}
header="Nhóm Sakura"
description="Cập nhật 5 phút trước"
action={<Button appearance="subtle" icon={<MoreHorizontal20Regular />} aria-label="Thêm thao tác" />}
/>
<CardContent>Bản phác thảo trang chủ mới đã sẵn sàng để góp ý.</CardContent>
{/* Chèn bất kỳ phần tử nào giữa các phần — thứ tự do bạn quyết định */}
<div className="flex gap-1">
<Badge appearance="tint" color="brand">Thiết kế</Badge>
<Badge appearance="tint" color="informative">Bản nháp</Badge>
</div>
<CardFooter>
<Button appearance="primary">Mở</Button>
<Button>Bỏ qua</Button>
</CardFooter>
</Card>| Họ component | Các phần |
|---|---|
| Card | Card · CardHeader (image, header, description, action) · CardPreview · CardContent · CardFooter |
| Dialog | Dialog · DialogTrigger · DialogSurface (modalType) · DialogTitle (action) · DialogDescription · DialogContent · DialogActions · DialogClose |
| Drawer | Drawer / OverlayDrawer / InlineDrawer · DrawerSurface · DrawerHeader · DrawerHeaderTitle (action) · DrawerBody · DrawerFooter |
| Table | Table · TableHeader · TableHeaderCell · TableBody · TableRow · TableCell · TableCellLayout · TableCellActions · TableSelectionCell |
| Menu | Menu · MenuTrigger · MenuPopover · MenuItem · MenuItemCheckbox · MenuItemRadio · MenuGroupHeader · MenuDivider · MenuSub… |
Render-prop trong Field
Field nối nhãn, gợi ý và validation với control. Truyền element khi control nhận id trực tiếp; truyền hàm khi bạn cần quyết định props đi vào đâu.
{/* 1. Element: Field clone và gắn id, aria-describedby, aria-invalid, required, disabled */}
<Field label="Tên dự án" hint="Hiển thị trên thanh tiêu đề." required>
<Input placeholder="Yugen Docs" />
</Field>
{/* 2. Render-prop: tự quyết định props đi vào đâu */}
<Field label="Quyền truy cập" hint="Có thể đổi sau trong Cài đặt.">
{(props) => (
<RadioGroup {...props} defaultValue="team" layout="horizontal">
<Radio value="private" label="Riêng tư" />
<Radio value="team" label="Trong nhóm" />
</RadioGroup>
)}
</Field>| Prop Field truyền xuống | Giá trị |
|---|---|
id | Prop id của Field hoặc id tự sinh (useId) — label trỏ tới bằng htmlFor. |
aria-describedby | id của validationMessage và hint (nếu có). |
aria-invalid | true khi validationState="error". |
required, disabled | Lấy từ prop cùng tên của Field. |
Với element, props đặt trực tiếp trên control thắng props của Field (Field gộp theo thứ tự { ...fieldProps, ...child.props }). Lưu ý <label for> không đặt tên được cho role="radiogroup" — với nhóm lựa chọn, xem cách gắn nhãn nhóm ở trang Gắn nhãn component.
rootClassName & className
Component có nhiều lớp DOM cho bạn nhắm style vào đúng lớp. Quy tắc chung: className vào phần nhìn thấy chính, rootClassName vào khối bao ngoài.
| Component | className | Prop phụ |
|---|---|---|
| Input, SearchBox | Khung ô nhập (viền, nền, thanh focus) | rootClassName — khối ngoài gồm label + hint; đặt width / margin ở đây |
| Slider | Radix root (rail + thumb) | rootClassName — khối ngoài gồm label + giá trị |
| InfoLabel | Thẻ label | rootClassName — span bọc label + nút (i) |
| Select | Khung ngoài (đặt width ở đây) | selectClassName — chính thẻ <select> |
| Textarea | Khung ngoài (đặt width ở đây) | textareaClassName — chính thẻ <textarea> |
| ProgressBar | Thanh tiến độ | rootClassName — khối ngoài khi có label / description |
| Dropdown, Combobox, TagPicker | Trigger / ô nhập | listboxClassName — bề mặt listbox trong portal |
| PopoverSurface, MenuPopover, TooltipContent | Bề mặt nổi trong portal | — |
<Input rootClassName="w-72" className="bg-background-2" placeholder="Khung nền xám, khối rộng 288px" />
<Slider rootClassName="max-w-xs" label="Âm lượng" defaultValue={[40]} />
<Select className="w-48" selectClassName="font-semibold">…</Select>
<Dropdown className="w-56" listboxClassName="max-h-60">…</Dropdown>- Dùng asChild để đổi thẻ gốc (link, link của router) thay vì bọc thêm một lớp.
- Truyền icon Fluent cỡ 20 vào prop icon / media và để component tự co giãn.
- Dùng render-prop của Field khi control không nhận id ở phần tử gốc.
- Không truyền nhiều hơn một con cho phần tử asChild.
- Không đặt width lên className của Input khi có label — dùng rootClassName.
- Không chép lại markup bên trong component để thêm một icon — dùng prop có sẵn.