style(knowledge): toolbar menus adopt the shared action-menu panel

The filter and sort menus were the last two in this toolbar still built
from raw DropdownMenu primitives, so they carried their own surface and
row geometry. Both now use ActionMenuContent / ActionMenuItem, which puts
them on the same 32px rows, 8px padding and 12px panel radius as every
other menu — 120px wide, centred on their trigger, scrolling inside past
240px instead of running down the viewport.

Two shared pieces make that possible:

- DropdownMenuCheckboxItem gains `indicatorSide`. Its check has always
  been a left gutter, which indents every label by 32px whether or not
  anything is checked; "right" keeps labels flush left and puts the check
  in a trailing slot. The default stays "left", so existing menus are
  untouched.
- ActionMenu exports ActionMenuCheckboxItem (that trailing-check row at
  ActionMenuItem's geometry) and actionMenuSectionLabelClassName for the
  group headings above them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kinyoo
2026-08-17 17:35:32 +08:00
parent 9ac2644a90
commit deda10b279
3 changed files with 100 additions and 25 deletions
@@ -1,5 +1,6 @@
import * as React from "react";
import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
} from "~/components/ui/DropdownMenu";
@@ -40,6 +41,10 @@ const iconDangerClassName = cn(iconBaseClassName, "text-[#F53F3F]");
const labelClassName = "min-w-0 truncate text-sm leading-[22px]";
/** Section heading above a group of rows: 12px/20 regular, muted, flush with the
* container's 8px padding — it sits 8px left of the row labels (Figma 13198:78112). */
export const actionMenuSectionLabelClassName = "text-xs leading-5 text-text-3";
/** Item-row style tokens, exported so non-DropdownMenuItem rows (e.g. a submenu
* trigger or a custom header) can match the same height / radius / typography. */
export const actionMenuItemClassName = itemRegularClassName;
@@ -125,6 +130,27 @@ export const ActionMenuItem = React.forwardRef<
);
});
type DropdownMenuCheckboxItemProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuCheckboxItem
>;
/** Checkable row of an action menu: same 32px geometry as ActionMenuItem, with
* the check in the trailing slot so labels stay flush left (Figma 13198:78111). */
export const ActionMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuCheckboxItem>,
DropdownMenuCheckboxItemProps
>(function ActionMenuCheckboxItem({ className, ...props }, ref) {
return (
<DropdownMenuCheckboxItem
ref={ref}
indicatorSide="right"
// 8px row radius per 基础-阴影与圆角规范 §控件 (32px row = large).
className={cn(itemRegularClassName, "rounded-lg", className)}
{...props}
/>
);
});
function renderIcon(icon: React.ReactNode, danger?: boolean): React.ReactNode {
if (icon == null || icon === false) return null;
const iconClass = danger ? iconDangerClassName : iconRegularClassName;
@@ -100,23 +100,37 @@ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className = '', children, checked, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
/** Where the check sits. "left" indents every label to keep them aligned;
* "right" keeps labels flush and puts the check in a trailing slot. */
indicatorSide?: 'left' | 'right';
}
>(({ className = '', children, checked, indicatorSide = 'left', ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm text-[#1D2129] outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-[#F2F3F5] data-[highlighted]:text-[#1D2129]',
'relative flex cursor-pointer select-none items-center rounded-lg py-1.5 text-sm text-[#1D2129] outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-[#F2F3F5] data-[highlighted]:text-[#1D2129]',
indicatorSide === 'left' ? 'pl-8 pr-2' : 'px-2',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4 text-primary" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{indicatorSide === 'left' && (
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4 text-primary" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
)}
{children}
{indicatorSide === 'right' && (
<span className="ml-auto flex size-4 shrink-0 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="size-4 text-primary" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
)}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
@@ -4,12 +4,15 @@ import { FileStatus, SortDirection, SortType } from "~/api/knowledge";
import { Checkbox } from "~/components";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/DropdownMenu";
import { knowledgeSpaceDropdownSurfaceClassName } from "~/components/SidebarListMoreMenu";
import {
ActionMenuCheckboxItem,
ActionMenuContent,
ActionMenuItem,
actionMenuLabelClassName,
actionMenuSectionLabelClassName,
} from "~/components/ActionMenu";
import { useLocalize } from "~/hooks";
import { cn } from "~/utils";
import { PENDING_REVIEW_FILTER, type FileStatusFilter } from "../knowledgeUtils";
@@ -137,21 +140,34 @@ export function FileListToolbar({
{localize("com_knowledge.filter")}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={knowledgeSpaceDropdownSurfaceClassName}>
<div className="px-2 py-1.5 text-xs font-medium text-text-3">
{/* 120px wide, 8px padding, 32px rows spaced 4px apart, 12px
panel radius (Figma 13198:78111 — the dropdown-panel radius
the 圆角规范 calls for). Past 240px the list scrolls inside
instead of running down the viewport. */}
<ActionMenuContent
align="center"
width={120}
// min-w-0 is required: the shared content base sets
// min-w-[8rem] (128px), which would otherwise win over
// the 120px width.
className="max-h-[240px] min-w-0 gap-1 overflow-y-auto rounded-xl"
>
<div className={actionMenuSectionLabelClassName}>
{localize("com_knowledge.filter_file_status")}
</div>
{statusOptions.map((option) => (
<DropdownMenuCheckboxItem
<ActionMenuCheckboxItem
key={option.value}
checked={statusFilter.includes(option.value)}
onCheckedChange={(checked) => onFilterStatus(option.value, checked)}
onSelect={(e) => e.preventDefault()}
>
{localize(option.labelKey)}
</DropdownMenuCheckboxItem>
<span className={actionMenuLabelClassName}>
{localize(option.labelKey)}
</span>
</ActionMenuCheckboxItem>
))}
</DropdownMenuContent>
</ActionMenuContent>
</DropdownMenu>
</>
)}
@@ -164,17 +180,36 @@ export function FileListToolbar({
{localize("com_knowledge.sort")}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={knowledgeSpaceDropdownSurfaceClassName}>
<div className="px-2 py-1.5 text-xs font-medium text-text-3">
{/* Same panel as the filter menu: 120px, centred on the trigger,
32px rows, 12px radius, scrolls past 240px. The active field's
direction sits in the trailing slot the filter uses for its check. */}
<ActionMenuContent
align="center"
width={120}
className="max-h-[240px] min-w-0 gap-1 overflow-y-auto rounded-xl"
>
<div className={actionMenuSectionLabelClassName}>
{localize("com_knowledge.sort_field")}
</div>
{SORT_OPTIONS.map((option) => (
<DropdownMenuItem key={option.value} onClick={() => onSort(option.value)}>
{localize(option.labelKey)}
{sortBy === option.value && (sortDirection === SortDirection.ASC ? " ↑" : " ↓")}
</DropdownMenuItem>
<ActionMenuItem
key={option.value}
className="rounded-lg"
onClick={() => onSort(option.value)}
>
<span className={actionMenuLabelClassName}>
{localize(option.labelKey)}
</span>
{sortBy === option.value && (
<span className="ml-auto flex size-4 shrink-0 items-center justify-center text-primary">
{sortDirection === SortDirection.ASC
? <Outlined.ArrowUp className="size-4" />
: <Outlined.ArrowDown className="size-4" />}
</span>
)}
</ActionMenuItem>
))}
</DropdownMenuContent>
</ActionMenuContent>
</DropdownMenu>
{showViewToggle && (