Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/hip-hoops-kneel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@knocklabs/react": patch
---

[Guides] Add FocusChin to highlight focus mode w/ dedicated controls
166 changes: 166 additions & 0 deletions packages/react/src/modules/guide/components/Toolbar/V2/FocusChin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { useGuideContext, useStore } from "@knocklabs/react-core";
import { Button } from "@telegraph/button";
import { Box, Stack } from "@telegraph/layout";
import { Tooltip } from "@telegraph/tooltip";
import { Text } from "@telegraph/typography";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import * as React from "react";

import { GUIDE_ROW_DATA_SELECTOR } from "./GuideRow";
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 maybeScrollGuideIntoView = (container: HTMLElement, guideKey: string) => {
requestAnimationFrame(() => {
const el = container.querySelector(
`[${GUIDE_ROW_DATA_SELECTOR}="${CSS.escape(guideKey)}"]`,
);
if (!el || !(el instanceof HTMLElement)) return;

const containerRect = container.getBoundingClientRect();
const elRect = el.getBoundingClientRect();

if (elRect.top < containerRect.top) {
container.scrollTo({
top:
container.scrollTop -
(containerRect.top - elRect.top) -
SCROLL_OVERSHOOT,
behavior: "smooth",
});
} else if (elRect.bottom > containerRect.bottom) {
container.scrollTo({
top:
container.scrollTop +
(elRect.bottom - containerRect.bottom) +
SCROLL_OVERSHOOT,
behavior: "smooth",
});
}
});
};

type Props = {
guides: InspectionResultOk["guides"];
guideListRef: React.RefObject<HTMLDivElement | null>;
};

export const FocusChin = ({ guides, guideListRef }: Props) => {
const { client } = useGuideContext();
const { debugSettings } = useStore(client.store, (state) => ({
debugSettings: state.debug,
}));

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

const isFocused = focusedKeys.length > 0;
if (!isFocused) {
return null;
}

const currentKey = focusedKeys[0]!;

return (
<Box
borderTop="px"
px="3"
py="1"
overflow="hidden"
backgroundColor="blue-2"
>
<Stack align="center" justify="space-between" gap="4">
<Text
as="span"
size="1"
weight="medium"
color="blue"
style={{
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
minWidth: 0,
}}
>
Focus mode: {currentKey}
</Text>
<Stack align="center" gap="1" style={{ flexShrink: 0 }}>
<Tooltip label="Focus previous guide">
<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);
}
}}
/>
</Tooltip>
<Tooltip label="Focus next guide">
<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);
}
}}
/>
</Tooltip>
<Tooltip label="Exit focus lock">
<Button
size="0"
variant="ghost"
color="blue"
leadingIcon={{ icon: X, alt: "Clear focus" }}
onClick={() => {
client.setDebug({ ...debugSettings, focusedGuideKeys: {} });
}}
/>
</Tooltip>
</Stack>
</Stack>
</Box>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
isUncommittedGuide,
} from "./useInspectGuideClientStore";

export const GUIDE_ROW_DATA_SELECTOR = "data-kgt-guide-row-key";

const Pill = ({
label,
color = "gray",
Expand Down Expand Up @@ -188,6 +190,7 @@ export const GuideRow = ({ guide, orderIndex, isExpanded, onClick }: Props) => {

const dots = getStatusDots(guide);
const summary = getStatusSummary(guide);
const dataAttrs = { [GUIDE_ROW_DATA_SELECTOR]: guide.key };

return (
<Box
Expand All @@ -199,6 +202,7 @@ export const GuideRow = ({ guide, orderIndex, isExpanded, onClick }: Props) => {
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{ cursor: "pointer" }}
{...dataAttrs}
>
<Stack
h="7"
Expand Down
112 changes: 73 additions & 39 deletions packages/react/src/modules/guide/components/Toolbar/V2/V2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ import { KnockButton } from "../KnockButton";
import { TOOLBAR_Z_INDEX } from "../shared";
import "../styles.css";

import { FocusChin } from "./FocusChin";
import { GuideContextDetails } from "./GuideContextDetails";
import { GuideRow } from "./GuideRow";
import { clearRunConfigLS, getRunConfig } from "./helpers";
import { DisplayOption, clearRunConfigLS, getRunConfig } from "./helpers";
import { useDraggable } from "./useDraggable";
import {
InspectionResultOk,
Expand All @@ -48,46 +49,21 @@ const Kbd = ({ children }: { children: React.ReactNode }) => {
);
};

type DisplayOption = "all-guides" | "only-eligible" | "only-displayable";

const GuidesList = ({
guides,
displayOption,
}: {
guides: InspectionResultOk["guides"];
displayOption: DisplayOption;
}) => {
const [expandedGuideRowKey, setExpandedGuideRowKey] = React.useState<
string | undefined
>();

React.useEffect(() => {
setExpandedGuideRowKey(undefined);
}, [displayOption]);

return guides.map((guide, idx) => {
const filterGuides = (
guides: InspectionResultOk["guides"],
displayOption: DisplayOption,
) => {
return guides.filter((guide) => {
const { isEligible, isQualified } = guide.annotation;
const isDisplayable = isEligible && isQualified;

if (displayOption === "only-displayable" && !isDisplayable) {
return null;
return false;
}
if (displayOption === "only-eligible" && !isEligible) {
return null;
return false;
}
return (
<GuideRow
key={guide.key}
guide={guide}
orderIndex={idx}
isExpanded={guide.key === expandedGuideRowKey}
onClick={() => {
setExpandedGuideRowKey((k) =>
k && k === guide.key ? undefined : guide.key,
);
}}
/>
);
return true;
});
};

Expand All @@ -100,6 +76,14 @@ export const V2 = () => {
const [isCollapsed, setIsCollapsed] = React.useState(false);
const [isContextPanelOpen, setIsContextPanelOpen] = React.useState(false);

const [expandedGuideRowKey, setExpandedGuideRowKey] = React.useState<
string | undefined
>();

React.useEffect(() => {
setExpandedGuideRowKey(undefined);
}, [displayOption]);

React.useEffect(() => {
const { isVisible = false, focusedGuideKeys = {} } = runConfig || {};
const isDebugging = client.store.state.debug?.debugging;
Expand Down Expand Up @@ -145,6 +129,7 @@ export const V2 = () => {
}, []);

const containerRef = React.useRef<HTMLDivElement>(null);
const guideListRef = React.useRef<HTMLDivElement>(null);
const { position, isDragging, handlePointerDown, hasDraggedRef } =
useDraggable({
elementRef: containerRef,
Expand All @@ -157,6 +142,9 @@ export const V2 = () => {
return null;
}

const guides =
result.status === "ok" ? filterGuides(result.guides, displayOption) : [];

return (
<Box
tgphRef={containerRef}
Expand Down Expand Up @@ -269,6 +257,29 @@ export const V2 = () => {
value={displayOption}
onValueChange={(val: DisplayOption) => {
if (!val) return;

const debugSettings = client.store.state.debug;

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

// Exit out of focus if the currently focused guide is not
// part of the selected list filter.
if (result.status === "ok" && focusedGuideKeys.length > 0) {
const currFocusedGuide = filterGuides(
result.guides,
val,
).find((g) => g.key === focusedGuideKeys[0]);

if (!currFocusedGuide) {
client.setDebug({
...debugSettings,
focusedGuideKeys: {},
});
}
}

setDisplayOption(val);
}}
>
Expand Down Expand Up @@ -343,7 +354,12 @@ export const V2 = () => {
)}

{/* Guide list content area */}
<Box p="1" overflow="auto" style={{ maxHeight: "calc(80vh - 96px)" }}>
<Box
tgphRef={guideListRef}
p="1"
overflow="auto"
style={{ maxHeight: "calc(80vh - 96px)" }}
>
{result.status === "error" ? (
<Box px="2" pb="1" style={{ lineHeight: "1.2" }}>
<Text
Expand All @@ -357,13 +373,31 @@ export const V2 = () => {
{result.message}
</Text>
</Box>
) : guides.length === 0 ? (
<Box px="2" pb="1" style={{ lineHeight: "1.2" }}>
<Text as="span" size="1" weight="medium" color="default">
No guides match the current filter.
</Text>
</Box>
) : (
<GuidesList
guides={result.guides}
displayOption={displayOption}
/>
guides.map((guide) => (
<GuideRow
key={guide.key}
guide={guide}
orderIndex={guide.orderIndex}
isExpanded={guide.key === expandedGuideRowKey}
onClick={() => {
setExpandedGuideRowKey((k) =>
k && k === guide.key ? undefined : guide.key,
);
}}
/>
))
)}
</Box>

{/* Focus chin with dedicated controls */}
<FocusChin guides={guides} guideListRef={guideListRef} />
</Stack>
)}
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { KnockGuide } from "@knocklabs/client";

import { checkForWindow } from "../../../../../modules/core";

export type DisplayOption = "all-guides" | "only-eligible" | "only-displayable";

// Use this param to start Toolbar and enter into a debugging session when
// it is present and set to true.
const TOOLBAR_QUERY_PARAM = "knock_guide_toolbar";
Expand Down
Loading
Loading