diff --git a/query-graphs/src/ui/CollapsiblePanel.css b/query-graphs/src/ui/CollapsiblePanel.css index 45d0b527..5af28dd2 100644 --- a/query-graphs/src/ui/CollapsiblePanel.css +++ b/query-graphs/src/ui/CollapsiblePanel.css @@ -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) { @@ -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; @@ -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 { @@ -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 { @@ -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); } diff --git a/query-graphs/src/ui/CollapsiblePanel.tsx b/query-graphs/src/ui/CollapsiblePanel.tsx index 800eabfe..4453b64a 100644 --- a/query-graphs/src/ui/CollapsiblePanel.tsx +++ b/query-graphs/src/ui/CollapsiblePanel.tsx @@ -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 { @@ -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. diff --git a/query-graphs/src/ui/IconButton.css b/query-graphs/src/ui/IconButton.css index 21b65cf5..0c4d47d1 100644 --- a/query-graphs/src/ui/IconButton.css +++ b/query-graphs/src/ui/IconButton.css @@ -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%); @@ -36,8 +36,8 @@ } & svg { - width: 1rem; - height: 1rem; + width: 1em; + height: 1em; fill: none; stroke: currentColor; stroke-width: 1.5; @@ -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; @@ -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%); diff --git a/query-graphs/src/ui/PanelSurface.css b/query-graphs/src/ui/PanelSurface.css new file mode 100644 index 00000000..03a875e6 --- /dev/null +++ b/query-graphs/src/ui/PanelSurface.css @@ -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; + } +} diff --git a/query-graphs/src/ui/QueryGraph.css b/query-graphs/src/ui/QueryGraph.css index e675f098..d7d2c8b8 100644 --- a/query-graphs/src/ui/QueryGraph.css +++ b/query-graphs/src/ui/QueryGraph.css @@ -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; @@ -23,6 +27,7 @@ & .react-flow__edge-text { font-size: .6em; + fill: currentColor; } & .react-flow__edge.qg-label-highlighted .react-flow__edge-text { @@ -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) { diff --git a/query-graphs/src/ui/QueryGraph.tsx b/query-graphs/src/ui/QueryGraph.tsx index 707a3f3e..3d57292e 100644 --- a/query-graphs/src/ui/QueryGraph.tsx +++ b/query-graphs/src/ui/QueryGraph.tsx @@ -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"; @@ -14,6 +14,8 @@ 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 { @@ -21,12 +23,8 @@ export interface QueryGraphProps { 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. */ - additionalControls?: ReactNode; } -export const QueryGraphControlButton = ControlButton; - interface QueryGraphInternalProps extends QueryGraphProps { nodeIdMapping: ReadonlyMap; treeParents: TreeParents; @@ -55,7 +53,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 ( + void fitView()}> + + + ); +} + +function ExpandedNodesButton() { const expandedNodes = useGraphRenderingStore((state) => state.expandedNodes); const setAllNodesExpanded = useGraphRenderingStore((state) => state.setAllNodesExpanded); const animateGraphChange = useAnimateGraphChange(); @@ -81,11 +91,22 @@ function ExpandedNodesControl() { }; return ( - + - + + ); +} + +/** Standard graph controls followed by any application-specific buttons. */ +export function GraphToolbar({children}: {children?: ReactNode}) { + return ( + + + + {children} + ); } @@ -93,7 +114,6 @@ function QueryGraphInternal({ treeDescription, children, animationSpeed = "medium", - additionalControls, nodeIdMapping, treeParents, }: QueryGraphInternalProps) { @@ -126,10 +146,6 @@ function QueryGraphInternal({ > {...Array.isArray(children) ? children : [children]} - - - {additionalControls} - ); diff --git a/query-graphs/src/ui/QueryNode.css b/query-graphs/src/ui/QueryNode.css index be8acf10..3695099e 100644 --- a/query-graphs/src/ui/QueryNode.css +++ b/query-graphs/src/ui/QueryNode.css @@ -2,7 +2,7 @@ display: flex; flex-direction: column; border: 1px solid transparent; - border-radius: .8em; + border-radius: var(--qg-panel-border-radius); padding: .3em .5em; transition-property: border-color, background-color, box-shadow; transition-duration: 0.2s; @@ -25,9 +25,9 @@ &.qg-expanded, &.qg-resizing { cursor: pointer; - background: white; - border-color: hsl(0,0%, 80%); - box-shadow: 1px 2px 5px hsl(0,0%,90%); + background-color: var(--qg-panel-background); + border-color: var(--qg-panel-border-color); + box-shadow: var(--qg-panel-shadow); } &.qg-highlighted { @@ -122,7 +122,7 @@ & .qg-subtree-handle-background { fill: hsl(0,0%,85%); - stroke: var(--xy-handle-border-color, var(--xy-handle-border-color-default)); + stroke: var(--qg-panel-border-color); stroke-width: 1.5; transition: fill 0.2s; } diff --git a/query-graphs/src/ui/QueryNode.tsx b/query-graphs/src/ui/QueryNode.tsx index 8f7051b9..4c7d3d54 100644 --- a/query-graphs/src/ui/QueryNode.tsx +++ b/query-graphs/src/ui/QueryNode.tsx @@ -5,6 +5,7 @@ import {Handle, Position} from "@xyflow/react"; import cc from "classcat"; import type {TreeNode} from "../tree-description"; import {NodeIcon} from "./NodeIcon"; +import "./PanelSurface.css"; import "./QueryNode.css"; import {useGraphRenderingStore} from "./store"; import {subtreeHandleId, useAnimateGraphChange} from "./useAnimatedGraphLayout"; diff --git a/standalone-app/src/QueryGraphsApp.tsx b/standalone-app/src/QueryGraphsApp.tsx index 4421c540..74223a69 100644 --- a/standalone-app/src/QueryGraphsApp.tsx +++ b/standalone-app/src/QueryGraphsApp.tsx @@ -5,7 +5,7 @@ import {FileOpener, useLoadStateController} from "./FileOpener"; import {loadPlanFromText} from "@tableau/query-graphs/lib/loaders"; import type {LoadedPlan} from "@tableau/query-graphs/lib/loaders"; import {insightPresets} from "@tableau/query-graphs/lib/insights/presets"; -import {QueryGraph} from "@tableau/query-graphs/lib/ui/QueryGraph"; +import {GraphToolbar, QueryGraph} from "@tableau/query-graphs/lib/ui/QueryGraph"; import {tryCreateLocalStorageUrl, isLocalStorageURL, loadLocalStorageURL} from "./LocalStorageUrl"; import {assert} from "./assert"; import {TreeLabel} from "./TreeLabel"; @@ -149,11 +149,7 @@ export function QueryGraphsApp() { } else { const {tree, format} = loadedPlan; return ( - } - > + + + + ); } diff --git a/standalone-app/src/SettingsPanel.css b/standalone-app/src/SettingsPanel.css index 69d51972..07c89b4b 100644 --- a/standalone-app/src/SettingsPanel.css +++ b/standalone-app/src/SettingsPanel.css @@ -1,27 +1,16 @@ .qg-settings-popover { - --qg-settings-surface: white; --qg-settings-text: hsl(0, 0%, 15%); - --qg-settings-border: hsl(0, 0%, 85%); - --qg-settings-divider: hsl(0, 0%, 90%); - --qg-settings-hover: hsl(0, 0%, 94%); --qg-settings-accent: hsl(210, 75%, 38%); --qg-settings-focus: hsl(210, 90%, 55%); position: fixed; - inset: auto auto 15px calc(15px + 26px + 0.5em); - box-sizing: border-box; + inset: auto auto 15px calc(15px + var(--qg-graph-toolbar-button-size) + 0.5em); inline-size: min(22em, calc(100vw - 2em)); max-block-size: calc(100dvh - 2em); margin: 0; padding: 0; overflow: auto; - border: 1px solid var(--qg-settings-border); - border-radius: 8px; color: var(--qg-settings-text); - background: var(--qg-settings-surface); - box-shadow: - 0 2px 6px hsl(0, 0%, 0%, 0.08), - 0 1px 2px hsl(0, 0%, 0%, 0.06); } .qg-settings-body { @@ -40,7 +29,7 @@ align-items: center; justify-content: space-between; padding: 0.5em 0.75em; - border-block-end: 1px solid var(--qg-settings-divider); + border-block-end: 1px solid var(--qg-panel-divider-color); & h2 { margin: 0; @@ -63,7 +52,7 @@ cursor: pointer; &:hover { - background: var(--qg-settings-hover); + background: var(--qg-panel-hover-background); } & svg { @@ -87,8 +76,8 @@ min-inline-size: 9em; padding: 0.25em; color: inherit; - background: var(--qg-settings-surface); - border: 1px solid var(--qg-settings-border); + background: var(--qg-panel-background); + border: 1px solid var(--qg-panel-border-color); border-radius: 0.25em; font: inherit; } @@ -96,7 +85,7 @@ .qg-settings-footer { padding: 0.65em 0.75em; - border-block-start: 1px solid var(--qg-settings-divider); + border-block-start: 1px solid var(--qg-panel-divider-color); & button { padding: 0; @@ -116,10 +105,11 @@ } .query-graph:has(.qg-settings-popover:popover-open) .qg-settings-button { - background: var( - --xy-controls-button-background-color-hover-props, - var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default)) - ); + background: var(--qg-panel-hover-background); +} + +.query-graph:has(.qg-settings-popover:popover-open) .qg-settings-button ~ .qg-icon-button-tooltip { + visibility: hidden; } @supports (position-area: inline-end) { @@ -149,7 +139,6 @@ } @media (forced-colors: active) { - .qg-settings-popover, .qg-settings-header, .qg-settings-footer, .qg-settings-row select { diff --git a/standalone-app/src/SettingsPanel.tsx b/standalone-app/src/SettingsPanel.tsx index f18843ee..e43e586a 100644 --- a/standalone-app/src/SettingsPanel.tsx +++ b/standalone-app/src/SettingsPanel.tsx @@ -1,6 +1,7 @@ import {animationSpeedOptions, usePrefersReducedMotion} from "@tableau/query-graphs/lib/ui/animation-timing"; import type {AnimationSpeed} from "@tableau/query-graphs/lib/ui/animation-timing"; -import {QueryGraphControlButton} from "@tableau/query-graphs/lib/ui/QueryGraph"; +import {IconButton} from "@tableau/query-graphs/lib/ui/IconButton"; +import "@tableau/query-graphs/lib/ui/PanelSurface.css"; import {editorKeybindingsOptions, themeOptions, useSettings} from "./settings"; import type {EditorKeybindings, Theme} from "./settings"; import "./SettingsPanel.css"; @@ -11,20 +12,14 @@ interface SettingsTargetProps { export function SettingsButton({popoverId}: SettingsTargetProps) { return ( - + - + ); } @@ -37,7 +32,13 @@ export function SettingsPanel({popoverId}: SettingsTargetProps) { const reducedMotionDescriptionId = `${popoverId}-reduced-motion`; return ( -