Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/smooth-lands-draw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@knocklabs/react": patch
---

[Guide] Add hotkey support for moving focus in guide toolbar
160 changes: 111 additions & 49 deletions packages/react/src/modules/guide/components/Toolbar/V2/FocusChin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ import { ChevronLeft, ChevronRight, X } from "lucide-react";
import * as React from "react";

import { GUIDE_ROW_DATA_SELECTOR } from "./GuideRow";
import { Kbd } from "./Kbd";
import { sharedTooltipProps } from "./helpers";
import { InspectionResultOk } from "./useInspectGuideClientStore";

// Extra scroll overshoot so the focused guide plus ~1-2 neighbors are visible,
// reducing how often consecutive next/prev clicks trigger a scroll.
const SCROLL_OVERSHOOT = 60;

const FOCUS_PREV_HOTKEY = ",";
const FOCUS_NEXT_HOTKEY = "/";

const maybeScrollGuideIntoView = (container: HTMLElement, guideKey: string) => {
requestAnimationFrame(() => {
const el = container.querySelector(
Expand Down Expand Up @@ -44,6 +48,50 @@ const maybeScrollGuideIntoView = (container: HTMLElement, guideKey: string) => {
});
};

type FocusNav = {
guides: InspectionResultOk["guides"];
currentKey: string | undefined;
focusGuide: (guideKey: string) => void;
};

const focusPrev = ({ guides, currentKey, focusGuide }: FocusNav) => {
const selectableGuides = guides.filter(
(g) => !!g.annotation.selectable.status,
);
if (selectableGuides.length === 0) return;

if (!currentKey) {
focusGuide(selectableGuides[selectableGuides.length - 1]!.key);
return;
}

const currIndex = selectableGuides.findIndex((g) => g.key === currentKey);
const prevGuide =
currIndex <= 0 ? undefined : selectableGuides[currIndex - 1];
if (!prevGuide) return;
focusGuide(prevGuide.key);
};

const focusNext = ({ guides, currentKey, focusGuide }: FocusNav) => {
const selectableGuides = guides.filter(
(g) => !!g.annotation.selectable.status,
);
if (selectableGuides.length === 0) return;

if (!currentKey) {
focusGuide(selectableGuides[0]!.key);
return;
}

const currIndex = selectableGuides.findIndex((g) => g.key === currentKey);
const nextGuide =
currIndex < 0 || currIndex + 1 > selectableGuides.length - 1
? undefined
: selectableGuides[currIndex + 1];
if (!nextGuide) return;
focusGuide(nextGuide.key);
};

type Props = {
guides: InspectionResultOk["guides"];
guideListRef: React.RefObject<HTMLDivElement | null>;
Expand All @@ -56,14 +104,50 @@ export const FocusChin = ({ guides, guideListRef }: Props) => {
}));

const focusedKeys = Object.keys(debugSettings?.focusedGuideKeys || {});

const isFocused = focusedKeys.length > 0;
const currentKey = focusedKeys[0];

const focusGuide = React.useCallback(
(guideKey: string) => {
client.setDebug({
...client.store.state.debug,
focusedGuideKeys: { [guideKey]: true },
});
if (guideListRef.current) {
maybeScrollGuideIntoView(guideListRef.current, guideKey);
}
},
[client, guideListRef],
);

// Latest-ref so the window keydown listener below can attach once and always
// read the current guides / focused key without needing to re-subscribe.
const latestRef = React.useRef<FocusNav>({ guides, currentKey, focusGuide });
React.useEffect(() => {
latestRef.current = { guides, currentKey, focusGuide };
});

React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!e.ctrlKey || e.repeat) return;
if (e.key === FOCUS_PREV_HOTKEY) {
e.preventDefault();
focusPrev(latestRef.current);
} else if (e.key === FOCUS_NEXT_HOTKEY) {
e.preventDefault();
focusNext(latestRef.current);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, []);

if (!isFocused) {
return null;
}

const currentKey = focusedKeys[0]!;

return (
<Box
borderTop="px"
Expand All @@ -89,64 +173,42 @@ export const FocusChin = ({ guides, guideListRef }: Props) => {
Focus mode: {currentKey}
</Text>
<Stack align="center" gap="1" style={{ flexShrink: 0 }}>
<Tooltip label="Focus previous guide" {...sharedTooltipProps}>
<Tooltip
label={
<Text as="span" size="1">
Focus previous guide
<Stack display="inline-block" ml="3">
<Kbd>ctrl</Kbd> + <Kbd>,</Kbd>
</Stack>
</Text>
}
{...sharedTooltipProps}
>
<Button
size="0"
variant="ghost"
color="blue"
leadingIcon={{ icon: ChevronLeft, alt: "Previous guide" }}
onClick={() => {
const selectableGuides = guides.filter(
(g) => !!g.annotation.selectable.status,
);
const currIndex = selectableGuides.findIndex(
(g) => g.key === currentKey,
);
const prevGuide =
currIndex <= 0 ? undefined : selectableGuides[currIndex - 1];

if (!prevGuide) return;

client.setDebug({
...debugSettings,
focusedGuideKeys: { [prevGuide.key]: true },
});

if (guideListRef.current) {
maybeScrollGuideIntoView(guideListRef.current, prevGuide.key);
}
}}
onClick={() => focusPrev(latestRef.current)}
/>
</Tooltip>
<Tooltip label="Focus next guide" {...sharedTooltipProps}>
<Tooltip
label={
<Text as="span" size="1">
Focus next guide
<Stack display="inline-block" ml="3">
<Kbd>ctrl</Kbd> + <Kbd>/</Kbd>
</Stack>
</Text>
}
{...sharedTooltipProps}
>
<Button
size="0"
variant="ghost"
color="blue"
leadingIcon={{ icon: ChevronRight, alt: "Next guide" }}
onClick={() => {
const selectableGuides = guides.filter(
(g) => !!g.annotation.selectable.status,
);
const currIndex = selectableGuides.findIndex(
(g) => g.key === currentKey,
);
const nextGuide =
currIndex < 0 || currIndex + 1 > selectableGuides.length - 1
? undefined
: selectableGuides[currIndex + 1];

if (!nextGuide) return;

client.setDebug({
...debugSettings,
focusedGuideKeys: { [nextGuide.key]: true },
});

if (guideListRef.current) {
maybeScrollGuideIntoView(guideListRef.current, nextGuide.key);
}
}}
onClick={() => focusNext(latestRef.current)}
/>
</Tooltip>
<Tooltip label="Exit focus mode" {...sharedTooltipProps}>
Expand Down
17 changes: 17 additions & 0 deletions packages/react/src/modules/guide/components/Toolbar/V2/Kbd.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as React from "react";

export const Kbd = ({ children }: { children: React.ReactNode }) => {
return (
<kbd
style={{
display: "inline-block",
padding: "1px 4px",
borderRadius: "var(--tgph-rounded-2)",
border: "1px solid rgba(255, 255, 255, 0.3)",
backgroundColor: "rgba(255, 255, 255, 0.15)",
}}
>
{children}
</kbd>
);
};
17 changes: 1 addition & 16 deletions packages/react/src/modules/guide/components/Toolbar/V2/V2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import "../styles.css";
import { FocusChin } from "./FocusChin";
import { GuideContextDetails } from "./GuideContextDetails";
import { GuideRow } from "./GuideRow";
import { Kbd } from "./Kbd";
import { DisplayOption, sharedTooltipProps } from "./helpers";
import { useDraggable } from "./useDraggable";
import {
Expand All @@ -43,22 +44,6 @@ const TOOLBAR_BOX_SHADOW = [
"0 8px 16px -4px rgba(0, 0, 0, 0.06)",
].join(", ");

const Kbd = ({ children }: { children: React.ReactNode }) => {
return (
<kbd
style={{
display: "inline-block",
padding: "1px 4px",
borderRadius: "var(--tgph-rounded-2)",
border: "1px solid rgba(255, 255, 255, 0.3)",
backgroundColor: "rgba(255, 255, 255, 0.15)",
}}
>
{children}
</kbd>
);
};

const getEmptyStateMessage = (displayOption: DisplayOption) => {
switch (displayOption) {
case "all-guides":
Expand Down
Loading