Fleet UI: Command palette keyboard will auto-expand sub-items, smooth scroll, no over expand (#47609)

This commit is contained in:
RachelElysia
2026-06-16 12:33:05 -04:00
committed by GitHub
parent b3e81f3b32
commit a950e245b5
3 changed files with 117 additions and 3 deletions
@@ -423,6 +423,62 @@ describe("CommandPalette", () => {
).toBeInTheDocument();
});
it("auto-expands sub-items when a parent is highlighted via arrow keys", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
// Sub-items hidden initially.
expect(screen.queryByText("Disk encryption")).not.toBeInTheDocument();
// Arrow down until OS settings is highlighted (aria-selected="true").
// Derive the upper bound from the number of rendered items so this
// doesn't silently miss if the list grows or reorders.
const itemCount = document.querySelectorAll(`.command-palette__item`)
.length;
const maxPresses = itemCount + 2;
let osSettingsItem: Element | null = null;
for (let i = 0; i < maxPresses; i += 1) {
osSettingsItem = screen
.getByText("OS settings")
.closest(`.command-palette__item`);
if (osSettingsItem?.getAttribute("aria-selected") === "true") {
break;
}
// eslint-disable-next-line no-await-in-loop
await user.keyboard("{ArrowDown}");
}
// Assert OS settings actually got selected so a failure here points
// at the navigation step, not at the expansion check below.
expect(osSettingsItem?.getAttribute("aria-selected")).toBe("true");
await waitFor(() => {
expect(screen.getByText("Disk encryption")).toBeInTheDocument();
});
});
it("does not auto-expand sub-items when a parent is hovered with the mouse", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
const osSettingsItem = screen
.getByText("OS settings")
.closest(`.command-palette__item`);
expect(osSettingsItem).toBeInTheDocument();
// Hovering moves cmdk's selected value (selection-follows-pointer)
// but must not pop sub-items open — that should only happen on
// keyboard nav. The expand/collapse bridge runs through a
// useEffect, so wrap the negative assertion in waitFor to make
// sure pending effects have flushed before we conclude that
// nothing expanded.
await user.hover(osSettingsItem!);
await waitFor(() => {
expect(screen.queryByText("Disk encryption")).not.toBeInTheDocument();
});
});
it("expands sub-items on chevron click", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
@@ -7,7 +7,7 @@ import React, {
useCallback,
useRef,
} from "react";
import { Command } from "cmdk";
import { Command, useCommandState } from "cmdk";
import {
Title as DialogTitle,
Description as DialogDescription,
@@ -44,6 +44,24 @@ import { isPreFilteredResult } from "./components/constants";
const baseClass = "command-palette";
// Subscribes to cmdk's selected value via its internal store. We can't
// rely on `Command.Dialog`'s `onValueChange` prop for this: in cmdk@1.1.1
// that callback only fires when `value` is also controlled, and the
// palette runs cmdk uncontrolled so arrow-key highlight changes never
// reach React without this bridge. Rendered inside `Command.Dialog` so
// the `useCommandState` context is available.
const HighlightSubscriber = ({
onChange,
}: {
onChange: (value: string) => void;
}) => {
const value = useCommandState((state) => state.value);
useEffect(() => {
onChange(value ?? "");
}, [value, onChange]);
return null;
};
// cmdk treats `value` as the row's identity *and* as the substring it
// filters against. Two rows with the same value collide. Including the
// item's id guarantees uniqueness without losing the user-typeable
@@ -471,14 +489,50 @@ const CommandPalette = (): JSX.Element | null => {
return map;
}, [items]);
// Track whether the most recent input was a keystroke or pointer move.
// Hovering changes cmdk's selected value (selection-follows-pointer),
// which would otherwise pop sub-items open every time the mouse passes
// a parent row. We still want auto-expand on arrow-key navigation, so
// gate it on this ref instead of turning off pointer selection
// entirely (which would also kill the hover highlight).
const lastInputSourceRef = useRef<"keyboard" | "pointer">("keyboard");
useEffect(() => {
if (!open) return undefined;
const onKey = () => {
lastInputSourceRef.current = "keyboard";
};
const onPointer = () => {
lastInputSourceRef.current = "pointer";
};
document.addEventListener("keydown", onKey, true);
document.addEventListener("pointermove", onPointer, true);
return () => {
document.removeEventListener("keydown", onKey, true);
document.removeEventListener("pointermove", onPointer, true);
};
}, [open]);
// Auto expand/collapse sub-items as the user arrows through items.
// Normalize to match how valueToParentId stores its keys — cmdk
// hands back the raw `value` prop (preserves casing/whitespace).
const handleHighlightChange = useCallback(
(value: string) => {
if (isSearching) return;
if (lastInputSourceRef.current !== "keyboard") return;
const parentId = valueToParentId.get(value.toLowerCase().trim());
setExpandedItems(parentId ? new Set([parentId]) : new Set());
// Bail out when the target set is equivalent to the current one
// (arrowing within the same parent's sub-items, or moving between
// two non-parent rows). Without this, every arrow press allocates
// a new Set and forces a re-render even when no expansion state
// actually changes.
setExpandedItems((prev) => {
if (parentId) {
if (prev.size === 1 && prev.has(parentId)) return prev;
return new Set([parentId]);
}
if (prev.size === 0) return prev;
return new Set();
});
},
[valueToParentId, isSearching]
);
@@ -750,7 +804,6 @@ const CommandPalette = (): JSX.Element | null => {
<Command.Dialog
open={open}
onOpenChange={handleOpenChange}
onValueChange={handleHighlightChange}
label="Command palette"
className={baseClass}
overlayClassName={`${baseClass}__overlay`}
@@ -772,6 +825,7 @@ const CommandPalette = (): JSX.Element | null => {
return 0;
}}
>
<HighlightSubscriber onChange={handleHighlightChange} />
{/* cmdk's Dialog wraps Radix Dialog.Content, which requires a Title and
a Description for screen reader accessibility — without these, Radix
logs a console error/warning on every open. Both are rendered
@@ -181,6 +181,10 @@
overflow-y: auto;
padding: $pad-small;
border-radius: $border-radius $border-radius 0 0;
// Give the highlighted row ~one item of breathing room when cmdk
// scrolls it into view ({ block: "nearest" }) — otherwise the new
// selection always parks flush against the top/bottom edge.
scroll-padding-block: 40px;
&::-webkit-scrollbar {
width: 6px;