Shortcut keys are working

This commit is contained in:
Matt Aitken
2023-06-20 17:45:24 +01:00
parent 1d230d9fed
commit 75edfccc31
17 changed files with 227 additions and 104 deletions
+8 -3
View File
@@ -3,6 +3,7 @@ import "../app/tailwind.css";
import { unstable_createRemixStub } from "@remix-run/testing";
import React from "react";
import { LocaleContextProvider } from "../app/components/primitives/LocaleProvider";
import { OperatingSystemContextProvider } from "../app/components/primitives/OperatingSystemProvider";
const preview: Preview = {
parameters: {
@@ -33,9 +34,13 @@ const preview: Preview = {
]);
return (
<LocaleContextProvider locales={window.navigator.languages as string[]}>
<RemixStub initialEntries={["/"]} />
</LocaleContextProvider>
<OperatingSystemContextProvider platform="mac">
<LocaleContextProvider
locales={window.navigator.languages as string[]}
>
<RemixStub initialEntries={["/"]} />
</LocaleContextProvider>
</OperatingSystemContextProvider>
);
},
],
@@ -31,7 +31,8 @@ export function NavBar() {
variant="secondary/small"
data-attr="posthog-feedback-button"
LeadingIcon={ChatBubbleLeftRightIcon}
shortcut="F"
shortcut={{ key: "f" }}
onClick={() => console.log("feedback")}
>
Send us feedback
</Button>
@@ -1,6 +1,11 @@
import { Link, LinkProps, NavLink, NavLinkProps } from "@remix-run/react";
import React from "react";
import { ShortcutDefinition } from "~/hooks/useShortcutKeys";
import React, {
ReactComponentElement,
forwardRef,
useImperativeHandle,
useRef,
} from "react";
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { NamedIcon } from "./NamedIcon";
import { ShortcutKey } from "./ShortcutKey";
@@ -238,8 +243,23 @@ type ButtonPropsType = Pick<
> &
React.ComponentProps<typeof ButtonContent>;
export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
({ type, disabled, onClick, ...props }, ref) => {
const innerRef = useRef<HTMLButtonElement>(null);
useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement);
if (props.shortcut) {
useShortcutKeys({
shortcut: props.shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
disabled,
});
}
return (
<button
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
@@ -248,7 +268,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
onClick={onClick}
name={props.name}
value={props.value}
ref={ref}
ref={innerRef}
form={props.form}
>
<ButtonContent {...props} />
@@ -260,10 +280,23 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
type LinkPropsType = Pick<LinkProps, "to" | "target"> &
React.ComponentProps<typeof ButtonContent>;
export const LinkButton = ({ to, ...props }: LinkPropsType) => {
const innerRef = useRef<HTMLAnchorElement>(null);
if (props.shortcut) {
useShortcutKeys({
shortcut: props.shortcut,
action: () => {
if (innerRef.current) {
innerRef.current.click();
}
},
});
}
if (to.toString().startsWith("http")) {
return (
<ExtLink
href={to.toString()}
ref={innerRef}
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
>
<ButtonContent {...props} />
@@ -273,6 +306,7 @@ export const LinkButton = ({ to, ...props }: LinkPropsType) => {
return (
<Link
to={to}
ref={innerRef}
className={cn("group outline-none", props.fullWidth ? "w-full" : "")}
>
<ButtonContent {...props} />
@@ -314,16 +348,19 @@ type ExtLinkProps = JSX.IntrinsicElements["a"] & {
href: string;
};
function ExtLink({ className, href, children, ...props }: ExtLinkProps) {
return (
<a
className={cn(className)}
target="_blank"
rel="noopener noreferrer"
href={href}
{...props}
>
{children}
</a>
);
}
const ExtLink = forwardRef<HTMLAnchorElement, ExtLinkProps>(
({ className, href, children, ...props }, ref) => {
return (
<a
className={cn(className)}
target="_blank"
rel="noopener noreferrer"
href={href}
ref={ref}
{...props}
>
{children}
</a>
);
}
);
@@ -0,0 +1,34 @@
import type { ReactNode } from "react";
import { useContext } from "react";
import { createContext } from "react";
export type OperatingSystemPlatform = "mac" | "windows";
type OperatingSystemContext = {
platform: OperatingSystemPlatform;
};
type OperatingSystemContextProviderProps = {
platform: OperatingSystemPlatform;
children: ReactNode;
};
const Context = createContext<OperatingSystemContext | null>(null);
export const OperatingSystemContextProvider = ({
platform,
children,
}: OperatingSystemContextProviderProps) => {
return <Context.Provider value={{ platform }}>{children}</Context.Provider>;
};
const throwIfNoProvider = () => {
throw new Error(
"Please wrap your application in an OperatingSystemContextProvider."
);
};
export const useOperatingSystem = () => {
const { platform } = useContext(Context) ?? throwIfNoProvider();
return { platform };
};
@@ -1,10 +1,9 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "~/utils/cn";
import { NamedIcon } from "./NamedIcon";
import { ShortcutKey } from "./ShortcutKey";
@@ -165,7 +164,7 @@ const SheetContent = React.forwardRef<
<NamedIcon name="close" className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
<ShortcutKey shortcut={{ all: { key: "esc" } }} variant="medium" />
<ShortcutKey shortcut={{ key: "esc" }} variant="small" />
</div>
<div className="flex max-h-full flex-col overflow-hidden">
{children}
@@ -212,4 +211,4 @@ export const SheetFooter = ({
</div>
);
export { Sheet, SheetTrigger, SheetContent };
export { Sheet, SheetContent, SheetTrigger };
@@ -1,7 +1,7 @@
import { Fragment } from "react";
import { useIsMac } from "~/hooks/useIsMac";
import { Modifier, ShortcutDefinition } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { useOperatingSystem } from "./OperatingSystemProvider";
const variants = {
small:
@@ -23,9 +23,10 @@ export function ShortcutKey({
variant,
className,
}: ShortcutKeyProps) {
const isMac = useIsMac();
const { platform } = useOperatingSystem();
const isMac = platform === "mac";
let relevantShortcut =
"all" in shortcut ? shortcut.all : isMac ? shortcut.mac : shortcut.windows;
"mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut;
const modifiers = relevantShortcut.modifiers ?? [];
const character = relevantShortcut.key;
@@ -87,19 +87,19 @@ function ButtonList({ primary }: { primary: string }) {
<Header3 className="mb-1 uppercase">Shortcut</Header3>
<Button
variant="primary/small"
shortcut={{ all: { key: "s", modifiers: ["meta"] } }}
shortcut={{ key: "s", modifiers: ["meta"] }}
>
Primary button
</Button>
<Button variant="secondary/small" shortcut={{ all: { key: "f" } }}>
<Button variant="secondary/small" shortcut={{ key: "f" }}>
Secondary button
</Button>
<Button variant="tertiary/small" shortcut={{ all: { key: "i" } }}>
<Button variant="tertiary/small" shortcut={{ key: "i" }}>
Tertiary button
</Button>
<Button
variant="danger/small"
shortcut={{ all: { key: "s", modifiers: ["meta"] } }}
shortcut={{ key: "s", modifiers: ["meta"] }}
>
Danger button
</Button>
@@ -203,25 +203,25 @@ function ButtonList({ primary }: { primary: string }) {
<Header3 className="mb-1 uppercase">Shortcut</Header3>
<Button
variant="primary/medium"
shortcut={{ all: { key: "s", modifiers: ["meta"] } }}
shortcut={{ key: "s", modifiers: ["meta"] }}
>
Primary button
</Button>
<Button
variant="secondary/medium"
shortcut={{ all: { key: "s", modifiers: ["meta"] } }}
shortcut={{ key: "s", modifiers: ["meta"] }}
>
Secondary button
</Button>
<Button
variant="tertiary/medium"
shortcut={{ all: { key: "s", modifiers: ["meta"] } }}
shortcut={{ key: "s", modifiers: ["meta"] }}
>
Tertiary button
</Button>
<Button
variant="danger/medium"
shortcut={{ all: { key: "s", modifiers: ["meta"] } }}
shortcut={{ key: "s", modifiers: ["meta"] }}
>
Danger button
</Button>
@@ -26,11 +26,7 @@ function ClipboardFieldExample() {
<ClipboardField
value="with leadingIcon"
variant="tertiary/small"
icon={
<EnvironmentLabel
environment={{ type: "PRODUCTION", slug: "PROD" }}
/>
}
icon={<EnvironmentLabel environment={{ type: "PRODUCTION" }} />}
/>
<ClipboardField
value="with leadingIcon"
@@ -43,11 +39,7 @@ function ClipboardFieldExample() {
<ClipboardField
value="with leadingIcon"
variant="tertiary/medium"
icon={
<EnvironmentLabel
environment={{ type: "DEVELOPMENT", slug: "DEV" }}
/>
}
icon={<EnvironmentLabel environment={{ type: "DEVELOPMENT" }} />}
/>
<ClipboardField
value="with leadingIcon"
@@ -74,11 +66,7 @@ function ClipboardFieldExample() {
<ClipboardField
value="with leadingIcon"
variant="tertiary/small"
icon={
<EnvironmentLabel
environment={{ type: "STAGING", slug: "STAGING" }}
/>
}
icon={<EnvironmentLabel environment={{ type: "STAGING" }} />}
secure={true}
/>
<ClipboardField
@@ -105,11 +93,7 @@ function ClipboardFieldExample() {
<ClipboardField
value="with leadingIcon"
variant="tertiary/medium"
icon={
<EnvironmentLabel
environment={{ type: "PRODUCTION", slug: "PROD" }}
/>
}
icon={<EnvironmentLabel environment={{ type: "PRODUCTION" }} />}
secure={true}
/>
<ClipboardField
@@ -42,7 +42,11 @@ function PageHeaders() {
<PageTitleRow>
<PageTitle title="Organizations" />
<PageButtons>
<LinkButton to={""} variant="primary/small" shortcut="N">
<LinkButton
to={""}
variant="primary/small"
shortcut={{ key: "n" }}
>
Create a new Organization
</LinkButton>
</PageButtons>
@@ -61,7 +65,11 @@ function PageHeaders() {
backButton={{ to: "#", text: "Orgs" }}
/>
<PageButtons>
<LinkButton to={""} variant="primary/small" shortcut="N">
<LinkButton
to={""}
variant="primary/small"
shortcut={{ key: "n" }}
>
Create a new Organization
</LinkButton>
</PageButtons>
@@ -3,6 +3,8 @@ import { Switch } from "../primitives/Switch";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { ShortcutDefinition } from "~/hooks/useShortcutKeys";
import { Button } from "../primitives/Buttons";
import { OperatingSystemContextProvider } from "../primitives/OperatingSystemProvider";
import { Header1 } from "../primitives/Headers";
const meta: Meta = {
title: "Primitives/ShortcutKey",
@@ -17,16 +19,26 @@ export const ShortcutKeys: Story = {
};
const shortcuts: ShortcutDefinition[] = [
{ all: { key: "esc" } },
{ all: { key: "f" } },
{ all: { key: "f", modifiers: ["meta"] } },
{ all: { key: "k", modifiers: ["meta"] } },
{ all: { key: "del", modifiers: ["alt", "ctrl"] } },
{ key: "esc" },
{ key: "f" },
{ key: "f", modifiers: ["meta"] },
{ key: "k", modifiers: ["meta"] },
{ key: "del", modifiers: ["alt", "ctrl"] },
];
function Collection() {
return (
<div className="flex flex-col items-start gap-y-4">
<Set platform="mac" />
<Set platform="windows" />
</div>
);
}
function Set({ platform }: { platform: "mac" | "windows" }) {
return (
<OperatingSystemContextProvider platform={platform}>
<Header1>{platform}</Header1>
{shortcuts.map((shortcut, index) => (
<div key={index} className="flex items-center gap-x-2">
<ShortcutKey shortcut={shortcut} variant="small" />
@@ -57,6 +69,6 @@ function Collection() {
</Button>
</div>
))}
</div>
</OperatingSystemContextProvider>
);
}
+8 -3
View File
@@ -4,12 +4,17 @@ import * as Sentry from "@sentry/remix";
import { useEffect } from "react";
import posthog from "posthog-js";
import { LocaleContextProvider } from "./components/primitives/LocaleProvider";
import { OperatingSystemContextProvider } from "./components/primitives/OperatingSystemProvider";
hydrateRoot(
document,
<LocaleContextProvider locales={window.navigator.languages as string[]}>
<RemixBrowser />
</LocaleContextProvider>
<OperatingSystemContextProvider
platform={window.navigator.userAgent.includes("Mac") ? "mac" : "windows"}
>
<LocaleContextProvider locales={window.navigator.languages as string[]}>
<RemixBrowser />
</LocaleContextProvider>
</OperatingSystemContextProvider>
);
if (process.env.NODE_ENV === "production") {
+40 -18
View File
@@ -8,6 +8,10 @@ import type { EntryContext, Headers } from "@remix-run/node"; // or cloudflare/d
import { parseAcceptLanguage } from "intl-parse-accept-language";
import isbot from "isbot";
import { LocaleContextProvider } from "./components/primitives/LocaleProvider";
import {
OperatingSystemContextProvider,
OperatingSystemPlatform,
} from "./components/primitives/OperatingSystemProvider";
const ABORT_DELAY = 30000;
@@ -22,6 +26,16 @@ export default function handleRequest(
validate: Intl.DateTimeFormat.supportedLocalesOf,
});
//get whether it's a mac or pc from the headers
const platform: OperatingSystemPlatform = request.headers
.get("user-agent")
?.includes("Mac")
? "mac"
: "windows";
console.log("User Agent", request.headers.get("user-agent"));
console.log("Server Platform", platform);
// If the request is from a bot, we want to wait for the full
// response to render before sending it to the client. This
// ensures that bots can see the full page content.
@@ -31,7 +45,8 @@ export default function handleRequest(
responseStatusCode,
responseHeaders,
remixContext,
locales
locales,
platform
);
}
@@ -40,7 +55,8 @@ export default function handleRequest(
responseStatusCode,
responseHeaders,
remixContext,
locales
locales,
platform
);
}
@@ -49,17 +65,20 @@ function serveTheBots(
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
locales: string[]
locales: string[],
platform: OperatingSystemPlatform
) {
return new Promise((resolve, reject) => {
const { pipe, abort } = renderToPipeableStream(
<LocaleContextProvider locales={locales}>
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>
</LocaleContextProvider>,
<OperatingSystemContextProvider platform={platform}>
<LocaleContextProvider locales={locales}>
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>
</LocaleContextProvider>
</OperatingSystemContextProvider>,
{
// Use onAllReady to wait for the entire document to be ready
onAllReady() {
@@ -87,18 +106,21 @@ function serveBrowsers(
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
locales: string[]
locales: string[],
platform: OperatingSystemPlatform
) {
return new Promise((resolve, reject) => {
let didError = false;
const { pipe, abort } = renderToPipeableStream(
<LocaleContextProvider locales={locales}>
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>
</LocaleContextProvider>,
<OperatingSystemContextProvider platform={platform}>
<LocaleContextProvider locales={locales}>
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>
</LocaleContextProvider>
</OperatingSystemContextProvider>,
{
// use onShellReady to wait until a suspense boundary is triggered
onShellReady() {
-11
View File
@@ -1,11 +0,0 @@
import { useEffect, useState } from "react";
export function useIsMac() {
const [isMac, setIsMac] = useState(false);
useEffect(() => {
setIsMac(navigator.platform.includes("Mac"));
}, []);
return isMac;
}
+29 -3
View File
@@ -1,4 +1,6 @@
import { useEffect, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { useOperatingSystem } from "~/components/primitives/OperatingSystemProvider";
export type Modifier = "alt" | "ctrl" | "meta" | "shift";
@@ -12,6 +14,30 @@ export type ShortcutDefinition =
windows: Shortcut;
mac: Shortcut;
}
| {
all: Shortcut;
};
| Shortcut;
type useShortcutKeysProps = {
shortcut: ShortcutDefinition;
action: (event: KeyboardEvent) => void;
disabled?: boolean;
};
export function useShortcutKeys({
shortcut,
action,
disabled = false,
}: useShortcutKeysProps) {
const keys = createKeysFromShortcut(shortcut);
useHotkeys(keys, action, { enabled: !disabled });
}
function createKeysFromShortcut(shortcut: ShortcutDefinition) {
const { platform } = useOperatingSystem();
const isMac = platform === "mac";
let relevantShortcut =
"mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut;
const modifiers = relevantShortcut.modifiers;
const character = relevantShortcut.key;
return modifiers ? modifiers.map((k) => k).join("+") + "+" : "" + character;
}
@@ -32,7 +32,7 @@ export function OrgAdminHeader() {
<LinkButton
to={newProjectPath(organization)}
variant="primary/small"
shortcut="N"
shortcut={{ key: "n" }}
>
Create a new project
</LinkButton>
@@ -439,10 +439,10 @@ function RerunPopover({
}) {
return (
<Popover>
<PopoverTrigger>
<ButtonContent variant="primary/small" shortcut="R">
<PopoverTrigger asChild={true}>
<Button variant="primary/small" shortcut={{ key: "R" }}>
Rerun Job
</ButtonContent>
</Button>
</PopoverTrigger>
<PopoverContent className="flex w-80 flex-col gap-2 p-4" align="end">
<Form method="post">
@@ -80,7 +80,7 @@ export default function Job() {
<LinkButton
to={jobTestPath(organization, project, job)}
variant="primary/small"
shortcut="T"
shortcut={{ key: "t" }}
>
Test
</LinkButton>