mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: implement <Kbd /> and shortcut tooltip (#21971)
Closes #21650 This pull-request adds a `<Tooltip />` with `<Kbd />` modifiers to the `Run Task` button describing the shortcut how to submit the prompt quickly without having to navigate to the `↑` button. <img width="456" height="298" alt="CleanShot 2026-02-06 at 19 40 58@2x" src="https://github.com/user-attachments/assets/fa08a373-21c3-4620-9551-0c8a6b3547ab" /> It should be noted that the [keyboard shortcut already existed](https://github.com/coder/coder/blob/jakehwll/21650-submit-prompt-shortcut/site/src/modules/tasks/TaskPrompt/TaskPrompt.tsx#L222-L227) so we don't need to implement that here. ```ts // L222-L227 const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { // Submit form on Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux) if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { onSubmit(e); } }; ``` --------- Co-authored-by: Danielle Maywood <danielle@themaywoods.com>
This commit is contained in:
co-authored by
Danielle Maywood
parent
edcee32ab9
commit
193e4bd73b
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { CommandIcon } from "lucide-react";
|
||||||
|
import { Kbd, KbdGroup } from "./Kbd";
|
||||||
|
|
||||||
|
const meta: Meta<typeof Kbd> = {
|
||||||
|
title: "components/Kbd",
|
||||||
|
component: Kbd,
|
||||||
|
args: {
|
||||||
|
children: "K",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Kbd>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const WithIcon: Story = {
|
||||||
|
args: {
|
||||||
|
children: <CommandIcon />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Group: Story = {
|
||||||
|
render: () => (
|
||||||
|
<KbdGroup>
|
||||||
|
<Kbd>
|
||||||
|
<CommandIcon />
|
||||||
|
</Kbd>
|
||||||
|
<Kbd>Enter</Kbd>
|
||||||
|
</KbdGroup>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MultipleKeys: Story = {
|
||||||
|
render: () => (
|
||||||
|
<KbdGroup>
|
||||||
|
<Kbd>Ctrl</Kbd>
|
||||||
|
<Kbd>Shift</Kbd>
|
||||||
|
<Kbd>P</Kbd>
|
||||||
|
</KbdGroup>
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { cn } from "utils/cn";
|
||||||
|
|
||||||
|
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center gap-1",
|
||||||
|
"h-5 w-fit min-w-5 font-sans text-xs font-medium select-none",
|
||||||
|
"bg-surface-tertiary text-content-secondary rounded-sm px-1",
|
||||||
|
"[&_svg:not([class*='size-'])]:size-3 pointer-events-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd-group"
|
||||||
|
className={cn("gap-1 inline-flex items-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export { Kbd, KbdGroup };
|
||||||
@@ -11,6 +11,7 @@ import { ErrorAlert } from "components/Alert/ErrorAlert";
|
|||||||
import { Button } from "components/Button/Button";
|
import { Button } from "components/Button/Button";
|
||||||
import { ExternalImage } from "components/ExternalImage/ExternalImage";
|
import { ExternalImage } from "components/ExternalImage/ExternalImage";
|
||||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||||
|
import { Kbd, KbdGroup } from "components/Kbd/Kbd";
|
||||||
import { Link } from "components/Link/Link";
|
import { Link } from "components/Link/Link";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -34,6 +35,7 @@ import TextareaAutosize, {
|
|||||||
type TextareaAutosizeProps,
|
type TextareaAutosizeProps,
|
||||||
} from "react-textarea-autosize";
|
} from "react-textarea-autosize";
|
||||||
import { docs } from "utils/docs";
|
import { docs } from "utils/docs";
|
||||||
|
import { getOSKey } from "utils/platform";
|
||||||
import { PromptSelectTrigger } from "./PromptSelectTrigger";
|
import { PromptSelectTrigger } from "./PromptSelectTrigger";
|
||||||
import { TemplateVersionSelect } from "./TemplateVersionSelect";
|
import { TemplateVersionSelect } from "./TemplateVersionSelect";
|
||||||
|
|
||||||
@@ -373,23 +375,34 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Tooltip>
|
||||||
size="icon"
|
<TooltipTrigger asChild>
|
||||||
type="submit"
|
<Button
|
||||||
disabled={prompt.trim().length === 0 || isMissingExternalAuth}
|
size="icon"
|
||||||
className="rounded-full disabled:bg-surface-invert-primary disabled:opacity-70"
|
type="submit"
|
||||||
>
|
disabled={prompt.trim().length === 0 || isMissingExternalAuth}
|
||||||
<Spinner
|
className="rounded-full disabled:bg-surface-invert-primary disabled:opacity-70"
|
||||||
loading={
|
>
|
||||||
isLoadingExternalAuth ||
|
<Spinner
|
||||||
isPollingExternalAuth ||
|
loading={
|
||||||
createTaskMutation.isPending
|
isLoadingExternalAuth ||
|
||||||
}
|
isPollingExternalAuth ||
|
||||||
>
|
createTaskMutation.isPending
|
||||||
<ArrowUpIcon />
|
}
|
||||||
</Spinner>
|
>
|
||||||
<span className="sr-only">Run task</span>
|
<ArrowUpIcon />
|
||||||
</Button>
|
</Spinner>
|
||||||
|
<span className="sr-only">Run task</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent align="end">
|
||||||
|
<KbdGroup>
|
||||||
|
<Kbd>{getOSKey()}</Kbd>
|
||||||
|
<span>+</span>
|
||||||
|
<Kbd>Enter</Kbd>
|
||||||
|
</KbdGroup>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Returns true if the current platform is macOS.
|
||||||
|
*/
|
||||||
|
function isMac(): boolean {
|
||||||
|
return !!navigator.platform.match("Mac");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the platform-appropriate modifier key label: ⌘ on macOS,
|
||||||
|
* Ctrl on everything else.
|
||||||
|
*/
|
||||||
|
export function getOSKey(): string {
|
||||||
|
return isMac() ? "⌘" : "Ctrl";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user