Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
29 changes: 11 additions & 18 deletions query-graphs/src/ui/CollapsiblePanel.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,6 @@
grid-template-columns: minmax(0, 1fr) auto;
/* Contain CodeMirror's z-index so the header can paint above it. */
isolation: isolate;
/* Match the card treatment used by hovered and expanded graph nodes. */
background: white;
border: 1px solid hsl(0, 0%, 85%);
border-radius: 8px;
box-shadow:
0 2px 6px hsl(0, 0%, 0%, 0.08),
0 1px 2px hsl(0, 0%, 0%, 0.06);

/* Lift an open tooltip's stacking context above subsequent sibling panels. */
&:has(.qg-icon-button:hover, .qg-icon-button:focus-visible, .qg-icon-button-tooltip:hover) {
Expand All @@ -35,7 +28,7 @@
/* Reset native button styles without losing keyboard semantics. */
border: 0;
border-radius: 0;
border-start-start-radius: 7px;
border-start-start-radius: var(--qg-panel-inner-radius);
color: inherit;
background: transparent;
font: inherit;
Expand Down Expand Up @@ -106,15 +99,15 @@
z-index: 1;
grid-column: 2;
grid-row: 1;
border-inline-start: 1px solid hsl(0, 0%, 85%);
border-start-end-radius: 7px;
border-inline-start: 1px solid var(--qg-panel-border-color);
border-start-end-radius: var(--qg-panel-inner-radius);

& > .qg-icon-button-wrapper {
align-self: stretch;
}

& > .qg-icon-button-wrapper + .qg-icon-button-wrapper {
border-inline-start: 1px solid hsl(0, 0%, 85%);
border-inline-start: 1px solid var(--qg-panel-border-color);
}

& .qg-icon-button {
Expand All @@ -126,33 +119,33 @@

& > .qg-icon-button-wrapper:last-child .qg-icon-button {
/* Follow the inside edge of the panel's top-end corner. */
border-start-end-radius: 7px;
border-start-end-radius: var(--qg-panel-inner-radius);
}
}

.qg-collapsible-panel:not([data-expanded]) > .qg-collapsible-panel-actions > .qg-icon-button-wrapper:last-child .qg-icon-button {
/* In a collapsed panel, the action also meets the bottom-end corner. */
border-end-end-radius: 7px;
border-end-end-radius: var(--qg-panel-inner-radius);
}

.qg-collapsible-panel:not([data-expanded]) > .qg-collapsible-panel-actions {
/* Clip the group's own background to the exposed bottom-end corner. */
border-end-end-radius: 7px;
border-end-end-radius: var(--qg-panel-inner-radius);
}

.qg-collapsible-panel:not([data-expanded]) > .qg-collapsible-panel-toggle {
/* Complete the bottom-start corner when content is hidden. */
border-end-start-radius: 7px;
border-end-start-radius: var(--qg-panel-inner-radius);
}

.qg-collapsible-panel:not(:has(> .qg-collapsible-panel-actions)) > .qg-collapsible-panel-toggle {
/* Complete the top-end corner when there is no action group. */
border-start-end-radius: 7px;
border-start-end-radius: var(--qg-panel-inner-radius);
}

.qg-collapsible-panel:not([data-expanded]):not(:has(> .qg-collapsible-panel-actions)) > .qg-collapsible-panel-toggle {
/* Complete the remaining corner when collapsed without actions. */
border-end-end-radius: 7px;
border-end-end-radius: var(--qg-panel-inner-radius);
}

.qg-collapsible-panel-title {
Expand Down Expand Up @@ -216,5 +209,5 @@
max-height: 70vh;
overflow: auto;
padding: 0.75em;
border-top: 1px solid hsl(0, 0%, 90%);
border-top: 1px solid var(--qg-panel-divider-color);
}
3 changes: 2 additions & 1 deletion query-graphs/src/ui/CollapsiblePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {useEffect, useId, useState, type ReactNode} from "react";
import cc from "classcat";
import "./PanelSurface.css";
import "./CollapsiblePanel.css";

export interface CollapsiblePanelProps {
Expand Down Expand Up @@ -37,7 +38,7 @@ export function CollapsiblePanel({
onOpenChange,
mountContentOnFirstIntent,
}: CollapsiblePanelProps) {
const classes = cc(["qg-collapsible-panel", {"qg-highlighted": highlighted}, className]);
const classes = cc(["qg-collapsible-panel", "qg-panel-surface", {"qg-highlighted": highlighted}, className]);
const [contentMounted, setContentMounted] = useState(open === true);
const [internalOpen, setInternalOpen] = useState(false);
// Choose the source of truth once; switching between controlled and uncontrolled state produces ambiguous behavior.
Expand Down
16 changes: 8 additions & 8 deletions query-graphs/src/ui/IconButton.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
width: 1.75em;
height: 1.75em;
box-sizing: border-box;
padding: 0;
border: 1px solid hsl(0, 0%, 70%);
Expand All @@ -36,8 +36,8 @@
}

& svg {
width: 1rem;
height: 1rem;
width: 1em;
height: 1em;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
Expand All @@ -53,9 +53,9 @@
inset-block-start: 100%;
inset-inline-end: 0;
width: max-content;
max-width: 16rem;
padding-block-start: 0.35rem;
font-size: 0.75rem;
max-width: 16em;
padding-block-start: 0.35em;
font-size: 0.75em;
font-weight: 400;
line-height: 1.2;
white-space: nowrap;
Expand All @@ -66,7 +66,7 @@
.qg-icon-button-tooltip > span {
/* Keep the outer padding transparent and hoverable as a pointer bridge. */
display: block;
padding: 0.25rem 0.4rem;
padding: 0.25em 0.4em;
border-radius: 3px;
color: white;
background: hsl(0, 0%, 20%);
Expand Down
25 changes: 25 additions & 0 deletions query-graphs/src/ui/PanelSurface.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
:where(.qg-panel-surface, .qg-graph-node) {
--qg-panel-background: white;
--qg-panel-border-color: hsl(0, 0%, 85%);
--qg-panel-divider-color: hsl(0, 0%, 90%);
--qg-panel-hover-background: hsl(0, 0%, 94%);
--qg-panel-border-radius: 0.5em;
--qg-panel-inner-radius: calc(var(--qg-panel-border-radius) - 1px);
--qg-panel-shadow:
0 2px 6px hsl(0, 0%, 0%, 0.08),
0 1px 2px hsl(0, 0%, 0%, 0.06);
}

.qg-panel-surface {
box-sizing: border-box;
border: 1px solid var(--qg-panel-border-color);
border-radius: var(--qg-panel-border-radius);
background-color: var(--qg-panel-background);
box-shadow: var(--qg-panel-shadow);
}

@media (forced-colors: active) {
.qg-panel-surface {
border-color: CanvasText;
}
}
65 changes: 65 additions & 0 deletions query-graphs/src/ui/QueryGraph.css
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
.query-graph {
--qg-graph-toolbar-button-size: 2em;

& .react-flow__pane {
background: #fdfdfd;
}

& .react-flow__handle {
width: 6px;
height: 6px;
background-color: transparent;
border: none;
pointer-events: none;
Expand All @@ -23,6 +27,7 @@

& .react-flow__edge-text {
font-size: .6em;
fill: currentColor;
}
Comment thread
vogelsgesang marked this conversation as resolved.

& .react-flow__edge.qg-label-highlighted .react-flow__edge-text {
Expand Down Expand Up @@ -54,6 +59,66 @@
stroke-width: 3px;
}
}

& .qg-graph-toolbar {
display: flex;
flex-direction: column;

& > .qg-icon-button-wrapper {
display: flex;
}

& > .qg-icon-button-wrapper + .qg-icon-button-wrapper {
border-block-start: 1px solid var(--qg-panel-divider-color);
}

& .qg-icon-button {
width: var(--qg-graph-toolbar-button-size);
height: var(--qg-graph-toolbar-button-size);
padding: 0.425em;
border: 0;
border-radius: 0;
background: transparent;

& svg {
width: 100%;
height: 100%;
fill: currentColor;
stroke: none;
}
}

& > .qg-icon-button-wrapper:first-child .qg-icon-button {
border-start-start-radius: var(--qg-panel-inner-radius);
border-start-end-radius: var(--qg-panel-inner-radius);
}

& > .qg-icon-button-wrapper:last-child .qg-icon-button {
border-end-start-radius: var(--qg-panel-inner-radius);
border-end-end-radius: var(--qg-panel-inner-radius);
}

& .qg-icon-button:hover:not(:disabled) {
background: var(--qg-panel-hover-background);
}

/* Open toolbar tooltips into the canvas rather than below the viewport. */
& .qg-icon-button-tooltip {
inset-block-start: 50%;
inset-inline: 100% auto;
padding-block-start: 0;
padding-inline-start: 0.35em;
transform: translateY(-50%);
}
}
}

@supports (anchor-scope: --qg-icon-button-anchor) and (position-area: inline-end) {
.query-graph .qg-graph-toolbar .qg-icon-button-tooltip {
inset: auto;
position-area: inline-end center;
transform: none;
}
}

@media (prefers-reduced-motion: reduce) {
Expand Down
55 changes: 40 additions & 15 deletions query-graphs/src/ui/QueryGraph.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {ReactFlow, MiniMap, MiniMapNode, Controls, ControlButton, ReactFlowProvider} from "@xyflow/react";
import {ReactFlow, MiniMap, MiniMapNode, Panel, ReactFlowProvider, useReactFlow} from "@xyflow/react";
import type {MiniMapNodeProps} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import "@xyflow/react/dist/base.css";

import type {TreeDescription, TreeNode} from "../tree-description";
import type {MouseEvent, ReactNode} from "react";
Expand All @@ -14,20 +14,20 @@ import {AnimateGraphChangeContext, useAnimatedGraphLayout, useAnimateGraphChange
import {indexGraph} from "./graph-index";
import type {TreeParents} from "./tree-topology";
import type {AnimationSpeed} from "./animation-timing";
import {IconButton} from "./IconButton";
import "./PanelSurface.css";
import "./QueryGraph.css";

export interface QueryGraphProps {
treeDescription: TreeDescription;
children: ReactNode | ReactNode[];
/** Selects the duration of graph layout transitions. Defaults to medium. */
animationSpeed?: AnimationSpeed;
/** Additional buttons, normally `QueryGraphControlButton`s, appended to the built-in graph controls. */
/** Additional `IconButton`s appended to the graph toolbar. */
additionalControls?: ReactNode;
}

export const QueryGraphControlButton = ControlButton;

interface QueryGraphInternalProps extends QueryGraphProps {
interface QueryGraphInternalProps extends Omit<QueryGraphProps, "additionalControls"> {
Comment thread
vogelsgesang marked this conversation as resolved.
Outdated
nodeIdMapping: ReadonlyMap<TreeNode, string>;
treeParents: TreeParents;
}
Expand Down Expand Up @@ -55,7 +55,19 @@ function preventNodeDoubleClickZoom(event: MouseEvent): void {
if (event.target instanceof Element && event.target.closest(".react-flow__node") !== null) event.stopPropagation();
}

function ExpandedNodesControl() {
function FitGraphButton() {
const {fitView} = useReactFlow();

return (
<IconButton label="Fit graph to view" onClick={() => void fitView()}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 4h6v2H6v4H4zm10 0h6v6h-2V6h-4zm4 10h2v6h-6v-2h4zM4 14h2v4h4v2H4z" />
</svg>
</IconButton>
);
}

function ExpandedNodesButton() {
const expandedNodes = useGraphRenderingStore((state) => state.expandedNodes);
const setAllNodesExpanded = useGraphRenderingStore((state) => state.setAllNodesExpanded);
const animateGraphChange = useAnimateGraphChange();
Expand All @@ -81,19 +93,28 @@ function ExpandedNodesControl() {
};

return (
<ControlButton onClick={onClick} title={label} aria-label={label} disabled={nodeExpansionStates.length === 0}>
<IconButton label={label} onClick={onClick} disabled={nodeExpansionStates.length === 0}>
<svg viewBox="0 0 24 24" aria-hidden="true">
{anyNodeExpanded ? <path d="M5 3h14l-7 7zM12 14l7 7H5z" /> : <path d="M12 3l7 7H5zM5 14h14l-7 7z" />}
</svg>
</ControlButton>
</IconButton>
);
}

function GraphToolbar({children}: {children?: ReactNode}) {
return (
<Panel className="qg-graph-toolbar qg-panel-surface" position="bottom-left" role="group" aria-label="Graph controls">
<FitGraphButton />
<ExpandedNodesButton />
{children}
</Panel>
);
}

function QueryGraphInternal({
treeDescription,
children,
animationSpeed = "medium",
additionalControls,
nodeIdMapping,
treeParents,
}: QueryGraphInternalProps) {
Expand Down Expand Up @@ -126,10 +147,6 @@ function QueryGraphInternal({
>
{...Array.isArray(children) ? children : [children]}
<MiniMap zoomable={true} pannable={true} nodeColor={minimapNodeColor} nodeComponent={QueryGraphMiniMapNode} />
<Controls showInteractive={false}>
<ExpandedNodesControl />
{additionalControls}
</Controls>
</ReactFlow>
</AnimateGraphChangeContext.Provider>
);
Expand All @@ -147,14 +164,22 @@ function createGraphState(treeDescription: TreeDescription) {
}

export function QueryGraph(props: QueryGraphProps) {
const {children, additionalControls, ...graphProps} = props;
const {instanceId, graphIndex, graphStore} = useMemo(() => createGraphState(props.treeDescription), [props.treeDescription]);

// This artificial key remounts React Flow when the tree changes, keeping
// its viewport, measurements, and animation state scoped to one graph.
return (
<ReactFlowProvider key={instanceId}>
<GraphRenderingStoreContext.Provider value={graphStore}>
<QueryGraphInternal {...props} nodeIdMapping={graphIndex.nodeIds} treeParents={graphIndex.treeTopology.parents} />
<QueryGraphInternal
{...graphProps}
nodeIdMapping={graphIndex.nodeIds}
treeParents={graphIndex.treeTopology.parents}
>
{children}
<GraphToolbar>{additionalControls}</GraphToolbar>
</QueryGraphInternal>
</GraphRenderingStoreContext.Provider>
</ReactFlowProvider>
);
Expand Down
Loading
Loading