@@ -40,6 +40,33 @@ const ANIMATION_CLASSES =
4040const MENU_ROW_HEIGHT_CLASS = 'h-[28px]'
4141const MENU_ROW_RADIUS_CLASS = 'rounded-lg'
4242
43+ /**
44+ * Rows settle instantly, matching the sidebar (`[&_.group.cursor-pointer]:duration-0`
45+ * on its `aside`). A menu is walked, not read: at the default 150ms the fill lags a
46+ * cursor dragged down the list and two or three rows are mid-fade at once, which reads
47+ * as smear rather than as one row following the pointer. `transition-colors` stays so a
48+ * consumer can opt a row back into a duration.
49+ */
50+ const MENU_ROW_TRANSITION_CLASS = 'transition-colors duration-0'
51+
52+ /**
53+ * The two row surfaces, mirroring `chipHoverSurfaceClass` / `chipActiveSurfaceClass`
54+ * — mutually exclusive, so a selected row holds its surface through hover instead of
55+ * dimming to the hover fill under the cursor.
56+ *
57+ * Highlight is keyed off `focus:`, not `hover:`: Radix moves DOM focus to the row on
58+ * pointer-move, so one selector covers both the pointer and the arrow-key cursor.
59+ * Rows previously highlighted to `--surface-active` — the *selected* surface — so a
60+ * hovered row looked selected and a menu appeared to have two selections at once. The
61+ * `group-*` variants are inert outside the `action` layout, which is the only place a
62+ * `group/dropdownitem` ancestor exists.
63+ */
64+ const MENU_ROW_HIGHLIGHT_CLASS =
65+ 'focus:bg-[var(--surface-hover)] group-focus-within/dropdownitem:bg-[var(--surface-hover)] group-hover/dropdownitem:bg-[var(--surface-hover)]'
66+ /** @see {@link MENU_ROW_HIGHLIGHT_CLASS } — the selected half of the same pair. */
67+ const MENU_ROW_SELECTED_CLASS =
68+ 'bg-[var(--surface-active)] focus:bg-[var(--surface-active)] group-focus-within/dropdownitem:bg-[var(--surface-active)] group-hover/dropdownitem:bg-[var(--surface-active)]'
69+
4370/**
4471 * Rows are a fixed height, so a label that wraps overflows its row and paints
4572 * over its neighbours instead of growing the row. Every row is therefore held
@@ -152,7 +179,10 @@ const DropdownMenuSubTrigger = React.forwardRef<
152179 < DropdownMenuPrimitive . SubTrigger
153180 ref = { ref }
154181 className = { cn (
155- `flex ${ MENU_ROW_HEIGHT_CLASS } min-w-0 cursor-default select-none items-center gap-2 ${ MENU_ROW_RADIUS_CLASS } px-2 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[state=open]:bg-[var(--surface-active)] ${ MENU_ROW_SINGLE_LINE_CLASS } [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]` ,
182+ /* An open submenu keeps its trigger on the selected surface — including while
183+ the pointer is on it, so walking into the submenu doesn't drop the trigger
184+ back to the hover fill. */
185+ `flex ${ MENU_ROW_HEIGHT_CLASS } min-w-0 cursor-default select-none items-center gap-2 ${ MENU_ROW_RADIUS_CLASS } px-2 text-[var(--text-body)] text-small outline-none ${ MENU_ROW_TRANSITION_CLASS } ${ MENU_ROW_HIGHLIGHT_CLASS } data-[state=open]:bg-[var(--surface-active)] data-[state=open]:focus:bg-[var(--surface-active)] ${ MENU_ROW_SINGLE_LINE_CLASS } [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]` ,
156186 inset && 'pl-7' ,
157187 className
158188 ) }
@@ -214,29 +244,41 @@ const DropdownMenuContent = React.forwardRef<
214244) )
215245DropdownMenuContent . displayName = DropdownMenuPrimitive . Content . displayName
216246
217- const DROPDOWN_MENU_ITEM_BASE_CLASSES = `relative flex ${ MENU_ROW_HEIGHT_CLASS } min-w-0 cursor-pointer select-none items-center gap-2 ${ MENU_ROW_RADIUS_CLASS } px-2 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50 ${ MENU_ROW_SINGLE_LINE_CLASS } [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]`
247+ const DROPDOWN_MENU_ITEM_BASE_CLASSES = `relative flex ${ MENU_ROW_HEIGHT_CLASS } min-w-0 cursor-pointer select-none items-center gap-2 ${ MENU_ROW_RADIUS_CLASS } px-2 text-[var(--text-body)] text-small outline-none ${ MENU_ROW_TRANSITION_CLASS } data-[disabled]:pointer-events-none data-[disabled]:opacity-50 ${ MENU_ROW_SINGLE_LINE_CLASS } [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]`
218248
219249const DropdownMenuItem = React . forwardRef <
220250 React . ElementRef < typeof DropdownMenuPrimitive . Item > ,
221251 React . ComponentPropsWithoutRef < typeof DropdownMenuPrimitive . Item > & {
222252 inset ?: boolean
253+ /**
254+ * Renders the row as selected — the current route, the checked value, the row
255+ * whose own menu is open. Selected is a state the row *holds*, so it keeps
256+ * `--surface-active` through hover rather than dimming to the hover fill.
257+ *
258+ * Not for a pointer/keyboard cursor: that is the row highlight, which the row
259+ * already paints on its own. A menu that marks its cursor row `active` puts two
260+ * selections on screen.
261+ */
262+ active ?: boolean
223263 /**
224264 * Optional inline action rendered on the right edge of the item — e.g. a
225265 * "more" icon button. Reveals on hover/focus of the row, and the row stays
226266 * highlighted while the cursor is over the action.
227267 */
228268 action ?: React . ReactNode
229269 }
230- > ( ( { className, inset, action, asChild, children, ...props } , ref ) => {
270+ > ( ( { className, inset, active , action, asChild, children, ...props } , ref ) => {
231271 const content = asChild ? children : withEllipsizedLabel ( children )
272+ const stateClasses = active ? MENU_ROW_SELECTED_CLASS : MENU_ROW_HIGHLIGHT_CLASS
232273 if ( action ) {
233274 return (
234275 < div className = 'group/dropdownitem relative' >
235276 < DropdownMenuPrimitive . Item
236277 ref = { ref }
237278 className = { cn (
238279 DROPDOWN_MENU_ITEM_BASE_CLASSES ,
239- 'pr-[28px] group-focus-within/dropdownitem:bg-[var(--surface-active)] group-hover/dropdownitem:bg-[var(--surface-active)]' ,
280+ stateClasses ,
281+ 'pr-[28px]' ,
240282 inset && 'pl-7' ,
241283 className
242284 ) }
@@ -254,7 +296,7 @@ const DropdownMenuItem = React.forwardRef<
254296 return (
255297 < DropdownMenuPrimitive . Item
256298 ref = { ref }
257- className = { cn ( DROPDOWN_MENU_ITEM_BASE_CLASSES , inset && 'pl-7' , className ) }
299+ className = { cn ( DROPDOWN_MENU_ITEM_BASE_CLASSES , stateClasses , inset && 'pl-7' , className ) }
258300 asChild = { asChild }
259301 { ...props }
260302 >
@@ -301,7 +343,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
301343 < DropdownMenuPrimitive . CheckboxItem
302344 ref = { ref }
303345 className = { cn (
304- `relative flex ${ MENU_ROW_HEIGHT_CLASS } min-w-0 cursor-default select-none items-center ${ MENU_ROW_RADIUS_CLASS } whitespace-nowrap pr-2 pl-7 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50` ,
346+ `relative flex ${ MENU_ROW_HEIGHT_CLASS } min-w-0 cursor-default select-none items-center ${ MENU_ROW_RADIUS_CLASS } whitespace-nowrap pr-2 pl-7 text-[var(--text-body)] text-small outline-none ${ MENU_ROW_TRANSITION_CLASS } ${ MENU_ROW_HIGHLIGHT_CLASS } data-[disabled]:pointer-events-none data-[disabled]:opacity-50` ,
305347 className
306348 ) }
307349 checked = { checked }
@@ -324,7 +366,7 @@ const DropdownMenuRadioItem = React.forwardRef<
324366 < DropdownMenuPrimitive . RadioItem
325367 ref = { ref }
326368 className = { cn (
327- `relative flex ${ MENU_ROW_HEIGHT_CLASS } min-w-0 cursor-default select-none items-center ${ MENU_ROW_RADIUS_CLASS } whitespace-nowrap pr-2 pl-7 text-[var(--text-body)] text-small outline-none transition-colors focus:bg-[var(--surface-active)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50` ,
369+ `relative flex ${ MENU_ROW_HEIGHT_CLASS } min-w-0 cursor-default select-none items-center ${ MENU_ROW_RADIUS_CLASS } whitespace-nowrap pr-2 pl-7 text-[var(--text-body)] text-small outline-none ${ MENU_ROW_TRANSITION_CLASS } ${ MENU_ROW_HIGHLIGHT_CLASS } data-[disabled]:pointer-events-none data-[disabled]:opacity-50` ,
328370 className
329371 ) }
330372 { ...props }
0 commit comments