mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-02 07:13:02 +00:00
feat(ui): rebuild the command palette on shadcn Command
Replace the hand-rolled combobox in the Cmd/Ctrl+K palette with shadcn's base-vega Command and CommandDialog (cmdk) and bind the shortcut with react-hotkeys-hook. cmdk now owns arrow keys, wraparound, Home/End and IME composition, Base UI keeps the dialog mounted for its closing animation and returns focus to the trigger, and mod+k is ignored while focus is inside another dialog Results keep their fixed Keys, Actions, Pages order with shouldFilter disabled. The highlight resets to the first row whenever debounced key results load, so Enter after typing still opens the top key cmdk 1.1.1 leaves aria-activedescendant empty or stale after it picks a row on its own (dip/cmdk#413), so a MutationObserver keeps it on the row that is actually aria-selected until upstream fixes it
This commit is contained in:
parent
da5ea59667
commit
ee9b27cd25
11 changed files with 1018 additions and 285 deletions
54
tests/e2e/ui/tests/navigation/commandPalette.spec.ts
Normal file
54
tests/e2e/ui/tests/navigation/commandPalette.spec.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { test, expect, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { dismissFeedbackPopup, navigateToPage } from "../../helpers/navigation";
|
||||
import { createVirtualKey, deleteVirtualKey, uniqueSuffix } from "../../helpers/traffic";
|
||||
|
||||
const paletteSearch = (page: PlaywrightPage) => page.getByRole("combobox", { name: "Search" });
|
||||
const headerTrigger = (page: PlaywrightPage) => page.getByRole("button", { name: "Search keys…", exact: true });
|
||||
|
||||
test.describe("Command palette", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await navigateToPage(page, Page.ApiKeys);
|
||||
await dismissFeedbackPopup(page);
|
||||
await expect(headerTrigger(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens a key found by its alias", async ({ page }) => {
|
||||
const alias = `e2e-palette-key-${uniqueSuffix()}`;
|
||||
const created = await createVirtualKey(page.request, { key_alias: alias });
|
||||
try {
|
||||
await page.keyboard.press("ControlOrMeta+K");
|
||||
await paletteSearch(page).fill(alias);
|
||||
await expect(page.getByRole("option", { name: new RegExp(`^${alias}`) })).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`[?&]key=${created.token}`));
|
||||
await expect(paletteSearch(page)).toBeHidden();
|
||||
} finally {
|
||||
await deleteVirtualKey(page.request, created.token);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not open over the Create New Key dialog", async ({ page }) => {
|
||||
await page.getByRole("button", { name: /Create New Key/i }).click();
|
||||
const keyName = page.getByLabel(/Key Name/);
|
||||
await keyName.click();
|
||||
|
||||
await page.keyboard.press("ControlOrMeta+K");
|
||||
await page.keyboard.type("still-in-the-dialog");
|
||||
|
||||
await expect(keyName).toHaveValue("still-in-the-dialog");
|
||||
await expect(paletteSearch(page)).toBeHidden();
|
||||
});
|
||||
|
||||
test("opens from the header trigger and closes with Escape", async ({ page }) => {
|
||||
await headerTrigger(page).click();
|
||||
await expect(paletteSearch(page)).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(paletteSearch(page)).toBeHidden();
|
||||
});
|
||||
});
|
||||
475
ui/litellm-dashboard/package-lock.json
generated
475
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -23,6 +23,7 @@
|
|||
"@types/papaparse": "5.5.2",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"dayjs": "1.11.19",
|
||||
"i18next": "26.5.11",
|
||||
|
|
@ -2656,6 +2657,323 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz",
|
||||
"integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
|
||||
"integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz",
|
||||
"integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.3.1.tgz",
|
||||
"integrity": "sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.2.0",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-context": "1.3.0",
|
||||
"@radix-ui/react-dismissable-layer": "1.2.1",
|
||||
"@radix-ui/react-focus-guards": "1.2.0",
|
||||
"@radix-ui/react-focus-scope": "1.3.1",
|
||||
"@radix-ui/react-id": "1.1.4",
|
||||
"@radix-ui/react-portal": "1.2.0",
|
||||
"@radix-ui/react-presence": "1.1.11",
|
||||
"@radix-ui/react-primitive": "2.2.0",
|
||||
"@radix-ui/react-slot": "1.5.0",
|
||||
"@radix-ui/react-use-controllable-state": "1.3.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz",
|
||||
"integrity": "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.2.0",
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.2.0",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.5",
|
||||
"@radix-ui/react-use-effect-event": "0.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.2.0.tgz",
|
||||
"integrity": "sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-scope": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.3.1.tgz",
|
||||
"integrity": "sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.2.0",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
|
||||
"integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.2.0.tgz",
|
||||
"integrity": "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.2.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz",
|
||||
"integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz",
|
||||
"integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz",
|
||||
"integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz",
|
||||
"integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz",
|
||||
"integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.2.0",
|
||||
"@radix-ui/react-use-effect-event": "0.0.5",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
|
||||
"integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
|
||||
"integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/ajv": {
|
||||
"version": "8.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
|
||||
|
|
@ -3939,7 +4257,7 @@
|
|||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
|
|
@ -4757,6 +5075,18 @@
|
|||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
|
|
@ -5308,6 +5638,22 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-primitive": "^2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
|
|
@ -5746,6 +6092,12 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node-es": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/devlop": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
||||
|
|
@ -6959,6 +7311,15 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
|
|
@ -10769,6 +11130,53 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-remove-scroll-bar": "^2.3.7",
|
||||
"react-style-singleton": "^2.2.3",
|
||||
"tslib": "^2.1.0",
|
||||
"use-callback-ref": "^1.3.3",
|
||||
"use-sidecar": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll-bar": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-style-singleton": "^2.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-resizable-panels": {
|
||||
"version": "4.14.1",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.14.1.tgz",
|
||||
|
|
@ -10779,6 +11187,28 @@
|
|||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-nonce": "^1.0.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-syntax-highlighter": {
|
||||
"version": "15.6.6",
|
||||
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz",
|
||||
|
|
@ -12408,6 +12838,49 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sidecar": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-node-es": "^1.1.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
"@types/papaparse": "5.5.2",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"dayjs": "1.11.19",
|
||||
"i18next": "26.5.11",
|
||||
|
|
|
|||
|
|
@ -435,10 +435,12 @@
|
|||
|
||||
/* Same plugin, resting state: it also gives every bare input a border, a white fill, 0.5rem/0.75rem
|
||||
padding and 1rem text. shadcn primitives style themselves and only break where a base class omits
|
||||
those, which today is `combobox-chip-input` alone (audited against every input-rendering file in
|
||||
components/ui). These values restore Tailwind's own preflight, and `[data-slot]` outweighs the
|
||||
plugin's zero-specificity `input:where(:not([type]))`. Delete along with the plugin. */
|
||||
[data-slot="combobox-chip-input"] {
|
||||
those, which today is `combobox-chip-input` and `command-input` (audited against every
|
||||
input-rendering file in components/ui). These values restore Tailwind's own preflight, and
|
||||
`[data-slot]` outweighs the plugin's zero-specificity `input:where(:not([type]))`. Delete along
|
||||
with the plugin. */
|
||||
[data-slot="combobox-chip-input"],
|
||||
[data-slot="command-input"] {
|
||||
border-width: 0;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
|
|
@ -449,7 +451,12 @@
|
|||
/* Same plugin's white fill, on the hand-rolled controls outside components/ui that the audit
|
||||
above did not cover. Tracks --background so light mode keeps the white it already painted.
|
||||
Delete along with the plugin. */
|
||||
:is(input, textarea, select):not([type="checkbox"], [type="radio"], [data-slot="combobox-chip-input"]) {
|
||||
:is(input, textarea, select):not(
|
||||
[type="checkbox"],
|
||||
[type="radio"],
|
||||
[data-slot="combobox-chip-input"],
|
||||
[data-slot="command-input"]
|
||||
) {
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
import type { KeyResponse } from "@/components/key_team_helpers/key_list";
|
||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { TerminologyProvider } from "@/contexts/TerminologyContext";
|
||||
import { writeStorage } from "@/lib/storage";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
|
|
@ -44,7 +45,8 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
|
|||
const HIGH_VOLUME_KEY = keyFixture("key-prod-0", "high-volume-prod", "sk-...4zoA", "team-1");
|
||||
const PROD_KEY = keyFixture("key-prod-1", "prod-backend", "sk-...AHeA", "team-1");
|
||||
const STAGING_KEY = keyFixture("key-staging-1", "staging-agent", "sk-...stgB", "unknown-team");
|
||||
const KEY_FIXTURES = [HIGH_VOLUME_KEY, PROD_KEY, STAGING_KEY];
|
||||
const LOGS_KEY = keyFixture("key-logs-1", "logs-writer", "sk-...logW");
|
||||
const KEY_FIXTURES = [HIGH_VOLUME_KEY, PROD_KEY, STAGING_KEY, LOGS_KEY];
|
||||
const UI_SETTINGS_RESPONSE = {
|
||||
server_root_path: "",
|
||||
proxy_base_url: null,
|
||||
|
|
@ -227,7 +229,7 @@ describe("CommandPalette integration", () => {
|
|||
|
||||
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
|
||||
const input = await screen.findByRole("combobox", { name: "Search" });
|
||||
expect(input).toHaveFocus();
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
fireEvent.change(input, { target: { value: "stale" } });
|
||||
|
||||
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
|
||||
|
|
@ -245,7 +247,7 @@ describe("CommandPalette integration", () => {
|
|||
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
|
||||
const input = await screen.findByRole("combobox", { name: "Search" });
|
||||
expect(input).toHaveAttribute("placeholder", "Search keys by alias or ID…");
|
||||
expect(screen.getAllByText("Virtual Keys")).toHaveLength(2);
|
||||
expect(screen.getByText("Virtual Keys")).toBeVisible();
|
||||
|
||||
await user.type(input, "prod");
|
||||
await waitFor(() => {
|
||||
|
|
@ -288,7 +290,7 @@ describe("CommandPalette integration", () => {
|
|||
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
|
||||
const input = screen.getByRole("combobox", { name: "Search" });
|
||||
fireEvent.change(input, { target: { value: "prod" } });
|
||||
vi.advanceTimersByTime(DEBOUNCE_WAIT_MS / 2);
|
||||
act(() => vi.advanceTimersByTime(DEBOUNCE_WAIT_MS / 2));
|
||||
|
||||
expect(screen.getByText("Searching…")).toBeVisible();
|
||||
expect(screen.queryByRole("option", { name: /high-volume-prod/ })).not.toBeInTheDocument();
|
||||
|
|
@ -359,10 +361,11 @@ describe("CommandPalette integration", () => {
|
|||
const input = await screen.findByRole("combobox", { name: "Search" });
|
||||
await user.type(input, "no-match");
|
||||
|
||||
expect(await screen.findByText("No keys match “no-match”")).toBeVisible();
|
||||
expect(screen.getByRole("group", { name: "Keys" })).toHaveTextContent("No keys match “no-match”");
|
||||
expect(screen.getByRole("option", { name: /Filter the keys table/ })).toBeVisible();
|
||||
expect(await screen.findByRole("option", { name: /Filter the keys table for “no-match”/ })).toBeVisible();
|
||||
expect(screen.getAllByRole("option")).toHaveLength(1);
|
||||
expect(screen.queryByRole("group", { name: "Keys" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("group", { name: "Pages" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/No results for/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows one global no-results message when the query has no matches", async () => {
|
||||
|
|
@ -375,8 +378,7 @@ describe("CommandPalette integration", () => {
|
|||
await user.type(input, "no-match");
|
||||
|
||||
expect(await screen.findByText("No results for “no-match”")).toBeVisible();
|
||||
expect(screen.queryByRole("group")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: /Search virtual keys/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("option")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the virtual-key table with the current search applied", async () => {
|
||||
|
|
@ -458,6 +460,21 @@ describe("CommandPalette integration", () => {
|
|||
expect(await screen.findByText("Recent keys")).toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps typing in the search box after clicking an action with the mouse", async () => {
|
||||
const user = userEvent.setup();
|
||||
mocks.pathname = "/teams";
|
||||
renderPalette();
|
||||
|
||||
await user.keyboard("{Control>}k{/Control}");
|
||||
await user.click(await screen.findByRole("option", { name: /Search virtual keys/ }));
|
||||
await user.keyboard("staging");
|
||||
|
||||
const input = screen.getByRole("combobox", { name: "Search" });
|
||||
expect(input).toHaveAttribute("placeholder", "Search keys by alias or ID…");
|
||||
expect(input).toHaveValue("staging");
|
||||
expect(await screen.findByRole("option", { name: /staging-agent/ })).toBeVisible();
|
||||
});
|
||||
|
||||
it("switches from an empty keys scope to global search on Backspace", async () => {
|
||||
renderPalette();
|
||||
fireEvent.keyDown(document, { key: "k", ctrlKey: true });
|
||||
|
|
@ -467,4 +484,128 @@ describe("CommandPalette integration", () => {
|
|||
fireEvent.keyDown(input, { key: "Backspace" });
|
||||
expect(input).toHaveAttribute("placeholder", "Search pages and actions…");
|
||||
});
|
||||
|
||||
it("ignores Cmd+K while another dialog has focus and still toggles the open palette closed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<CommandPaletteProvider>
|
||||
<CommandPaletteTrigger />
|
||||
<Dialog>
|
||||
<DialogTrigger>Create key</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>Create key</DialogTitle>
|
||||
<input aria-label="Key alias" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CommandPaletteProvider>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Create key" }));
|
||||
const aliasInput = await screen.findByRole("textbox", { name: "Key alias" });
|
||||
await user.click(aliasInput);
|
||||
await user.keyboard("{Control>}k{/Control}");
|
||||
|
||||
expect(screen.queryByRole("combobox", { name: "Search" })).not.toBeInTheDocument();
|
||||
expect(aliasInput).toHaveFocus();
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("dialog", { name: "Create key" })).not.toBeInTheDocument();
|
||||
|
||||
await user.keyboard("{Control>}k{/Control}");
|
||||
const paletteInput = await screen.findByRole("combobox", { name: "Search" });
|
||||
await waitFor(() => expect(paletteInput).toHaveFocus());
|
||||
|
||||
await user.keyboard("{Control>}k{/Control}");
|
||||
expect(screen.queryByRole("combobox", { name: "Search" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("orders Keys, then Actions, then Pages for a query that matches all three", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPalette();
|
||||
|
||||
await user.keyboard("{Control>}k{/Control}");
|
||||
await user.type(await screen.findByRole("combobox", { name: "Search" }), "logs");
|
||||
await screen.findByRole("option", { name: /logs-writer/ });
|
||||
|
||||
const rows = screen.getAllByRole("option");
|
||||
expect(rows.length).toBeGreaterThanOrEqual(3);
|
||||
expect(rows[0]).toHaveTextContent("logs-writer");
|
||||
expect(rows[0]).toHaveTextContent("Key");
|
||||
expect(rows[1]).toHaveTextContent("Filter the keys table for “logs”");
|
||||
expect(rows.slice(2).every((row) => row.textContent?.endsWith("Page"))).toBe(true);
|
||||
expect(screen.getByRole("option", { name: /^Logs/ })).toBeVisible();
|
||||
});
|
||||
|
||||
it("points assistive tech at the highlighted row as the palette opens, filters and moves", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPalette();
|
||||
|
||||
await user.keyboard("{Control>}k{/Control}");
|
||||
const input = await screen.findByRole("combobox", { name: "Search" });
|
||||
const expectHighlighted = (name: RegExp) => {
|
||||
const row = screen.getByRole("option", { name });
|
||||
expect(row).toHaveAttribute("aria-selected", "true");
|
||||
expect(input).toHaveAttribute("aria-activedescendant", row.id);
|
||||
};
|
||||
|
||||
await waitFor(() => expectHighlighted(/high-volume-prod/));
|
||||
await user.type(input, "prod");
|
||||
await waitFor(() => expectHighlighted(/high-volume-prod/));
|
||||
await user.keyboard("{ArrowDown}");
|
||||
expectHighlighted(/prod-backend/);
|
||||
});
|
||||
|
||||
it("moves the highlight back to the top row when the query changes after arrowing down", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPalette();
|
||||
|
||||
await user.keyboard("{Control>}k{/Control}");
|
||||
const input = await screen.findByRole("combobox", { name: "Search" });
|
||||
await user.type(input, "pro");
|
||||
const prodBackend = await screen.findByRole("option", { name: /prod-backend/ });
|
||||
await user.keyboard("{ArrowDown}");
|
||||
expect(prodBackend).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await user.type(input, "d");
|
||||
await screen.findByRole("option", { name: /prod-backend/ });
|
||||
expect(screen.getByRole("option", { name: /high-volume-prod/ })).toHaveAttribute("aria-selected", "true");
|
||||
await user.keyboard("{Enter}");
|
||||
expect(mocks.push).toHaveBeenCalledWith(keyDetailHref(HIGH_VOLUME_KEY.token));
|
||||
});
|
||||
|
||||
it("stays in key search when Backspace deletes typed text", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPalette();
|
||||
|
||||
await user.keyboard("{Control>}k{/Control}");
|
||||
const input = await screen.findByRole("combobox", { name: "Search" });
|
||||
await user.type(input, "pro{Backspace}");
|
||||
|
||||
expect(input).toHaveValue("pr");
|
||||
expect(input).toHaveAttribute("placeholder", "Search keys by alias or ID…");
|
||||
});
|
||||
|
||||
it("shows the key search error instead of results when keys fail to load", async () => {
|
||||
vi.mocked(useKeys).mockImplementation(
|
||||
() =>
|
||||
({ data: undefined, isFetching: false, isError: true, isPlaceholderData: false }) as ReturnType<typeof useKeys>,
|
||||
);
|
||||
renderPalette();
|
||||
|
||||
await userEvent.setup().keyboard("{Control>}k{/Control}");
|
||||
|
||||
expect(await screen.findByText("Unable to search keys")).toBeVisible();
|
||||
expect(screen.queryByRole("option")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens with Cmd+K on macOS and leaves Ctrl+K to the browser there", async () => {
|
||||
vi.spyOn(navigator, "userAgent", "get").mockReturnValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)");
|
||||
renderPalette();
|
||||
|
||||
expect(fireEvent.keyDown(document, { key: "k", ctrlKey: true })).toBe(true);
|
||||
expect(screen.queryByRole("combobox", { name: "Search" })).not.toBeInTheDocument();
|
||||
|
||||
expect(fireEvent.keyDown(document, { key: "k", metaKey: true })).toBe(false);
|
||||
expect(await screen.findByRole("combobox", { name: "Search" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,51 +1,98 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { useDebouncedValue } from "@tanstack/react-pacer/debouncer";
|
||||
import { FileText, KeyRound, LoaderCircle, Search } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Kbd } from "@/components/ui/kbd";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import { InputGroupAddon } from "@/components/ui/input-group";
|
||||
import { useTerminology } from "@/contexts/TerminologyContext";
|
||||
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
|
||||
import { keyDetailHref } from "@/utils/entityLinks";
|
||||
import { routeSegmentForPathname, uiHref } from "@/utils/uiHref";
|
||||
import type { KeyResponse } from "@/components/key_team_helpers/key_list";
|
||||
import { CommandPaletteResultRow, type CommandPaletteRowData } from "./CommandPaletteResultRow";
|
||||
import { useVisibleMenuGroups } from "./useVisibleMenuGroups";
|
||||
import { flattenNavItems, matchNavItems, paletteScopeForRoute, type PaletteNavItem, type PaletteScope } from "./utils";
|
||||
|
||||
type PaletteEntry = CommandPaletteRowData &
|
||||
(
|
||||
| { kind: "key"; key: KeyResponse }
|
||||
| { kind: "filter" }
|
||||
| { kind: "search-keys" }
|
||||
| { kind: "page"; page: PaletteNavItem }
|
||||
);
|
||||
type PaletteEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
icon: ReactNode;
|
||||
typeLabel: "Key" | "Page" | "Action";
|
||||
} & (
|
||||
| { kind: "key"; key: KeyResponse }
|
||||
| { kind: "filter" }
|
||||
| { kind: "search-keys" }
|
||||
| { kind: "page"; page: PaletteNavItem }
|
||||
);
|
||||
|
||||
interface PaletteGroup {
|
||||
label: string;
|
||||
heading: string;
|
||||
entries: PaletteEntry[];
|
||||
emptyMessage?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const emptyMessageFor = (scope: PaletteScope, query: string, hasKeyError: boolean): string => {
|
||||
if (hasKeyError) return "Unable to search keys";
|
||||
if (scope === "keys" && !query) return "No recent keys";
|
||||
return `No results for “${query}”`;
|
||||
};
|
||||
|
||||
// TODO(https://github.com/dip/cmdk/issues/413): remove once cmdk keeps aria-activedescendant on the selected row
|
||||
function useActiveDescendantOnSelectedRow(inputRef: RefObject<HTMLInputElement | null>) {
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
const root = input?.closest("[cmdk-root]");
|
||||
if (!input || !root) return;
|
||||
const sync = () => {
|
||||
const rowId = root.querySelector('[cmdk-item][aria-selected="true"]')?.id ?? null;
|
||||
if (input.getAttribute("aria-activedescendant") === rowId) return;
|
||||
if (rowId) input.setAttribute("aria-activedescendant", rowId);
|
||||
else input.removeAttribute("aria-activedescendant");
|
||||
};
|
||||
const observer = new MutationObserver(sync);
|
||||
observer.observe(root, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributeFilter: ["aria-selected", "aria-activedescendant"],
|
||||
});
|
||||
sync();
|
||||
return () => observer.disconnect();
|
||||
}, [inputRef]);
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
|
||||
if (!open) return null;
|
||||
return <CommandPaletteDialog setOpen={setOpen} />;
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Command palette"
|
||||
description="Search for a page, an action or a virtual key"
|
||||
>
|
||||
<CommandPaletteContent setOpen={setOpen} />
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandPaletteDialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
||||
function CommandPaletteContent({ setOpen }: { setOpen: (open: boolean) => void }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useActiveDescendantOnSelectedRow(inputRef);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeSelection, setActiveSelection] = useState<{
|
||||
id: string;
|
||||
query: string;
|
||||
scope: PaletteScope;
|
||||
} | null>(null);
|
||||
const [scope, setScope] = useState<PaletteScope>(() => paletteScopeForRoute(routeSegmentForPathname(pathname)));
|
||||
const [debouncedQuery] = useDebouncedValue(query, { wait: DEBOUNCE_WAIT_MS });
|
||||
const trimmedQuery = query.trim();
|
||||
|
|
@ -66,6 +113,7 @@ function CommandPaletteDialog({ setOpen }: { setOpen: (open: boolean) => void })
|
|||
const hasPlaceholderKeyResults = keyResults.isPlaceholderData;
|
||||
const isInitialKeyLoad = keyResults.isFetching && !keyResults.data;
|
||||
const isLoadingKeys = scope === "keys" && (isPendingQuery || hasPlaceholderKeyResults || isInitialKeyLoad);
|
||||
const hasKeyError = scope === "keys" && keyResults.isError && keyResults.data === undefined;
|
||||
const visibleGroups = useVisibleMenuGroups();
|
||||
const term = useTerminology();
|
||||
const navItems = useMemo(() => flattenNavItems(visibleGroups, term), [visibleGroups, term]);
|
||||
|
|
@ -74,94 +122,70 @@ function CommandPaletteDialog({ setOpen }: { setOpen: (open: boolean) => void })
|
|||
[navItems, query, scope, trimmedQuery],
|
||||
);
|
||||
const groups = useMemo<PaletteGroup[]>(() => {
|
||||
if (hasKeyError) return [];
|
||||
|
||||
const pages: PaletteEntry[] = navMatches.map((page) => ({
|
||||
id: `page:${page.route}`,
|
||||
kind: "page",
|
||||
title: page.label,
|
||||
subtitle: page.section,
|
||||
icon: page.icon ?? <FileText className="size-4" />,
|
||||
icon: page.icon ?? <FileText />,
|
||||
typeLabel: "Page",
|
||||
page,
|
||||
}));
|
||||
const pagesGroups = pages.length > 0 ? [{ heading: "Pages", entries: pages }] : [];
|
||||
|
||||
if (scope === "global") {
|
||||
const searchKeysAction = !trimmedQuery || "search virtual keys".includes(trimmedQuery.toLowerCase());
|
||||
return [
|
||||
...(searchKeysAction
|
||||
? [
|
||||
{
|
||||
label: "Actions",
|
||||
entries: [
|
||||
{
|
||||
id: "action:search-keys",
|
||||
kind: "search-keys" as const,
|
||||
title: "Search virtual keys",
|
||||
subtitle: "Find a key by alias or ID",
|
||||
icon: <KeyRound className="size-4" />,
|
||||
typeLabel: "Action" as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(pages.length > 0 ? [{ label: "Pages", entries: pages }] : []),
|
||||
];
|
||||
const showSearchKeysAction = !trimmedQuery || "search virtual keys".includes(trimmedQuery.toLowerCase());
|
||||
const searchKeysAction: PaletteEntry = {
|
||||
id: "action:search-keys",
|
||||
kind: "search-keys",
|
||||
title: "Search virtual keys",
|
||||
subtitle: "Find a key by alias or ID",
|
||||
icon: <KeyRound />,
|
||||
typeLabel: "Action",
|
||||
};
|
||||
return [...(showSearchKeysAction ? [{ heading: "Actions", entries: [searchKeysAction] }] : []), ...pagesGroups];
|
||||
}
|
||||
|
||||
const keyEntries: PaletteEntry[] = (isLoadingKeys ? [] : keyResults.data?.keys ?? []).map((key) => {
|
||||
const teamAlias = key.team_alias || (key.team_id ? teamAliases.get(key.team_id) : undefined);
|
||||
const spend = typeof key.spend === "number" && Number.isFinite(key.spend) ? `$${key.spend.toFixed(2)}` : null;
|
||||
const details = [teamAlias, spend].filter((detail): detail is string => Boolean(detail));
|
||||
const subtitle: ReactNode = (
|
||||
<>
|
||||
<span className="font-mono">{key.key_name}</span>
|
||||
{details.length > 0 && <span> · {details.join(" · ")}</span>}
|
||||
</>
|
||||
);
|
||||
return {
|
||||
id: `key:${key.token}`,
|
||||
kind: "key",
|
||||
title: key.key_alias || "Unnamed key",
|
||||
subtitle,
|
||||
icon: <KeyRound className="size-4" />,
|
||||
subtitle: (
|
||||
<>
|
||||
<span className="font-mono">{key.key_name}</span>
|
||||
{details.length > 0 && <span> · {details.join(" · ")}</span>}
|
||||
</>
|
||||
),
|
||||
icon: <KeyRound />,
|
||||
typeLabel: "Key",
|
||||
key,
|
||||
};
|
||||
});
|
||||
const keysGroup: PaletteGroup = {
|
||||
label: trimmedQuery ? "Keys" : "Recent keys",
|
||||
entries: keyEntries,
|
||||
emptyMessage: trimmedQuery ? `No keys match “${trimmedQuery}”` : "No recent keys",
|
||||
isLoading: isLoadingKeys,
|
||||
};
|
||||
const keysGroups =
|
||||
keyEntries.length > 0 ? [{ heading: trimmedQuery ? "Keys" : "Recent keys", entries: keyEntries }] : [];
|
||||
|
||||
if (!trimmedQuery) return [keysGroup];
|
||||
if (!trimmedQuery) return keysGroups;
|
||||
|
||||
const filterEntry: PaletteEntry = {
|
||||
const filterAction: PaletteEntry = {
|
||||
id: "action:filter-keys",
|
||||
kind: "filter",
|
||||
title: `Filter the keys table for “${trimmedQuery}”`,
|
||||
subtitle: "Open Virtual Keys with this search applied",
|
||||
icon: <Search className="size-4" />,
|
||||
icon: <Search />,
|
||||
typeLabel: "Action",
|
||||
};
|
||||
return [...keysGroups, { heading: "Actions", entries: [filterAction] }, ...pagesGroups];
|
||||
}, [hasKeyError, isLoadingKeys, keyResults.data?.keys, navMatches, scope, teamAliases, trimmedQuery]);
|
||||
|
||||
return [
|
||||
keysGroup,
|
||||
{ label: "Actions", entries: [filterEntry] },
|
||||
...(pages.length > 0 ? [{ label: "Pages", entries: pages }] : []),
|
||||
];
|
||||
}, [isLoadingKeys, keyResults.data?.keys, navMatches, scope, teamAliases, trimmedQuery]);
|
||||
const selectableEntries = useMemo(() => groups.flatMap((group) => group.entries), [groups]);
|
||||
const activeEntryId = activeSelection?.query === query && activeSelection.scope === scope ? activeSelection.id : null;
|
||||
const selectedIndex = selectableEntries.findIndex((entry) => entry.id === activeEntryId);
|
||||
const activeIndex = selectableEntries.length === 0 ? -1 : Math.max(0, selectedIndex);
|
||||
const activeOptionId = activeIndex >= 0 ? `command-palette-option-${activeIndex}` : undefined;
|
||||
const hasKeyError = scope === "keys" && keyResults.isError && keyResults.data === undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (activeOptionId) document.getElementById(activeOptionId)?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [activeOptionId]);
|
||||
const resultSet = [scope, query, isLoadingKeys].join("\n");
|
||||
const [highlight, setHighlight] = useState<{ value: string; resultSet: string } | null>(null);
|
||||
const highlightedValue = highlight?.resultSet === resultSet ? highlight.value : groups[0]?.entries[0]?.id ?? "";
|
||||
|
||||
const activate = (entry: PaletteEntry) => {
|
||||
switch (entry.kind) {
|
||||
|
|
@ -184,136 +208,63 @@ function CommandPaletteDialog({ setOpen }: { setOpen: (open: boolean) => void })
|
|||
}
|
||||
};
|
||||
|
||||
const handleInputKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Backspace" && scope === "keys" && query === "") {
|
||||
event.preventDefault();
|
||||
setScope("global");
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const activeEntry = selectableEntries[activeIndex];
|
||||
if (activeEntry) activate(activeEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
||||
const leaveKeysScopeOnBackspace = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== "Backspace" || scope !== "keys" || query !== "") return;
|
||||
event.preventDefault();
|
||||
if (selectableEntries.length === 0) return;
|
||||
const currentIndex = activeIndex < 0 ? 0 : activeIndex;
|
||||
const nextIndex =
|
||||
event.key === "ArrowDown"
|
||||
? (currentIndex + 1) % selectableEntries.length
|
||||
: (currentIndex - 1 + selectableEntries.length) % selectableEntries.length;
|
||||
setActiveSelection({ id: selectableEntries[nextIndex].id, query, scope });
|
||||
};
|
||||
|
||||
const renderGroupContents = (group: PaletteGroup, firstOptionIndex: number): ReactNode => {
|
||||
if (group.isLoading) {
|
||||
return (
|
||||
<div className="flex h-20 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
Searching…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (group.entries.length === 0)
|
||||
return <div className="px-2 py-3 text-sm text-muted-foreground">{group.emptyMessage}</div>;
|
||||
return group.entries.map((entry, entryIndex) => {
|
||||
const currentIndex = firstOptionIndex + entryIndex;
|
||||
return (
|
||||
<CommandPaletteResultRow
|
||||
key={entry.id}
|
||||
item={entry}
|
||||
optionId={`command-palette-option-${currentIndex}`}
|
||||
active={activeIndex === currentIndex}
|
||||
onActivate={() => activate(entry)}
|
||||
onHover={() => setActiveSelection({ id: entry.id, query, scope })}
|
||||
/>
|
||||
);
|
||||
});
|
||||
setScope("global");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
overlayClassName="bg-black/35 backdrop-blur-sm"
|
||||
className="top-[15vh] max-h-[70vh] translate-y-0 gap-0 overflow-hidden rounded-xl border border-border p-0 shadow-2xl sm:max-w-[640px]"
|
||||
>
|
||||
<DialogTitle className="sr-only">Command palette</DialogTitle>
|
||||
<div className="flex h-14 items-center gap-3 border-b border-border px-4">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||
{scope === "keys" && (
|
||||
<span className="flex shrink-0 items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs font-medium">
|
||||
<KeyRound className="size-3.5" />
|
||||
<Command
|
||||
label="Search"
|
||||
shouldFilter={false}
|
||||
loop
|
||||
value={highlightedValue}
|
||||
onValueChange={(value) => setHighlight({ value, resultSet })}
|
||||
onKeyDown={leaveKeysScopeOnBackspace}
|
||||
>
|
||||
<div className="flex items-center *:data-[slot=command-input-wrapper]:flex-1">
|
||||
{scope === "keys" && (
|
||||
<InputGroupAddon>
|
||||
<Badge variant="secondary">
|
||||
<KeyRound />
|
||||
Virtual Keys
|
||||
</Badge>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
<CommandInput
|
||||
ref={inputRef}
|
||||
placeholder={scope === "keys" ? "Search keys by alias or ID…" : "Search pages and actions…"}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
</div>
|
||||
<CommandList onMouseDown={(event) => event.preventDefault()}>
|
||||
{isLoadingKeys && !hasKeyError ? (
|
||||
<CommandPrimitive.Loading label="Searching…" className="py-6 text-center text-sm text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
Searching…
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-label="Search"
|
||||
aria-expanded="true"
|
||||
aria-controls="command-palette-results"
|
||||
aria-activedescendant={activeOptionId}
|
||||
aria-autocomplete="list"
|
||||
className="h-full min-w-0 flex-1 border-0 bg-transparent text-[15px] outline-none placeholder:text-muted-foreground focus:ring-0"
|
||||
placeholder={scope === "keys" ? "Search keys by alias or ID…" : "Search pages and actions…"}
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div id="command-palette-results" role="listbox" className="max-h-[400px] overflow-y-auto p-2">
|
||||
{hasKeyError && (
|
||||
<div className="flex h-20 items-center justify-center text-sm text-muted-foreground">
|
||||
Unable to search keys
|
||||
</div>
|
||||
)}
|
||||
{!hasKeyError && scope === "global" && trimmedQuery && groups.length === 0 && (
|
||||
<div className="px-2 py-3 text-sm text-muted-foreground">No results for “{trimmedQuery}”</div>
|
||||
)}
|
||||
{!hasKeyError &&
|
||||
groups.map((group, groupIndex) => {
|
||||
const firstOptionIndex = groups
|
||||
.slice(0, groupIndex)
|
||||
.reduce((count, previousGroup) => count + previousGroup.entries.length, 0);
|
||||
return (
|
||||
<div key={group.label} role="group" aria-label={group.label}>
|
||||
<div
|
||||
role="presentation"
|
||||
className="px-2 py-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{group.label}
|
||||
</div>
|
||||
{renderGroupContents(group, firstOptionIndex)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex h-10 items-center justify-between border-t border-border bg-muted/30 px-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">LiteLLM</span>
|
||||
<span>{scope === "keys" ? "Virtual Keys" : "All pages"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<Kbd>↵</Kbd> Open
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Kbd>↑↓</Kbd> Navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Kbd>esc</Kbd> Close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CommandPrimitive.Loading>
|
||||
) : (
|
||||
<CommandEmpty>{emptyMessageFor(scope, trimmedQuery, hasKeyError)}</CommandEmpty>
|
||||
)}
|
||||
{groups.map((group) => (
|
||||
<CommandGroup key={group.heading} heading={group.heading}>
|
||||
{group.entries.map((entry) => (
|
||||
<CommandItem key={entry.id} value={entry.id} onSelect={() => activate(entry)}>
|
||||
{entry.icon}
|
||||
<span className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="max-w-full shrink-0 truncate">{entry.title}</span>
|
||||
{entry.subtitle && <span className="truncate text-xs text-muted-foreground">{entry.subtitle}</span>}
|
||||
</span>
|
||||
<CommandShortcut>{entry.typeLabel}</CommandShortcut>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { useStoredValue } from "@/lib/storage";
|
||||
import { routeSegmentForPathname } from "@/utils/uiHref";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
|
|
@ -16,6 +17,9 @@ interface CommandPaletteContextValue {
|
|||
|
||||
const CommandPaletteContext = createContext<CommandPaletteContextValue | null>(null);
|
||||
|
||||
const isInsideDialog = (event: KeyboardEvent): boolean =>
|
||||
event.target instanceof Element && event.target.closest("[role='dialog'], [role='alertdialog']") !== null;
|
||||
|
||||
export function useCommandPalette(): CommandPaletteContextValue {
|
||||
const context = useContext(CommandPaletteContext);
|
||||
if (!context) throw new Error("useCommandPalette must be used within CommandPaletteProvider");
|
||||
|
|
@ -43,17 +47,13 @@ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
|
|||
const value = useMemo(() => ({ open, setOpen, toggle }), [open, setOpen, toggle]);
|
||||
const shouldShowHint = !hintSeen && !hintDismissedInMemory && !open;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k" && !event.shiftKey && !event.altKey) {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggle]);
|
||||
const hotkeyOptions = {
|
||||
enableOnFormTags: true,
|
||||
preventDefault: true,
|
||||
useKey: true,
|
||||
ignoreEventWhen: (event: KeyboardEvent) => !open && isInsideDialog(event),
|
||||
};
|
||||
useHotkeys("mod+k", toggle, hotkeyOptions);
|
||||
|
||||
return (
|
||||
<CommandPaletteContext.Provider value={value}>
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
export interface CommandPaletteRowData {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
icon: ReactNode;
|
||||
typeLabel: "Key" | "Page" | "Action";
|
||||
}
|
||||
|
||||
export function CommandPaletteResultRow({
|
||||
item,
|
||||
optionId,
|
||||
active,
|
||||
onActivate,
|
||||
onHover,
|
||||
}: {
|
||||
item: CommandPaletteRowData;
|
||||
optionId: string;
|
||||
active: boolean;
|
||||
onActivate: () => void;
|
||||
onHover: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
id={optionId}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"flex h-11 w-full items-center gap-3 rounded-lg px-2 text-left transition-colors",
|
||||
active ? "bg-accent text-accent-foreground" : "hover:bg-muted/70",
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onActivate}
|
||||
onMouseMove={onHover}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted">{item.icon}</span>
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span className="truncate text-sm font-medium">{item.title}</span>
|
||||
{item.subtitle && <span className="truncate text-xs text-muted-foreground">{item.subtitle}</span>}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{item.typeLabel}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -32,18 +32,18 @@ export function CommandPaletteTrigger({ mobile = false }: { mobile?: boolean })
|
|||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="hidden h-8 w-64 shrink-0 items-center justify-between rounded-lg border border-border bg-muted/40 px-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted lg:w-80 md:flex"
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden w-64 justify-between md:flex lg:w-80"
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Search className="size-4 shrink-0" />
|
||||
<Search />
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
<Kbd className="ml-2 shrink-0">{shortcut}</Kbd>
|
||||
</button>
|
||||
<Kbd>{shortcut}</Kbd>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
153
ui/litellm-dashboard/src/components/ui/command.tsx
Normal file
153
ui/litellm-dashboard/src/components/ui/command.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
|
||||
import { SearchIcon, CheckIcon } from "lucide-react";
|
||||
|
||||
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = false,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
showCloseButton?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn("w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", className)}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn("no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("-mx-1 h-px w-auto bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({ className, children, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:**:[svg]:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||
</CommandPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
|
|
@ -257,6 +257,9 @@ if (typeof HTMLElement !== "undefined") {
|
|||
},
|
||||
});
|
||||
}
|
||||
if (typeof Element !== "undefined" && typeof Element.prototype.scrollIntoView !== "function") {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
if (typeof document !== "undefined" && !("elementFromPoint" in document)) {
|
||||
Object.defineProperty(document, "elementFromPoint", { configurable: true, value: () => null });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue