A bunch of responsiveness fixes

This commit is contained in:
Gregory Schier
2025-07-09 14:24:29 -07:00
parent d9f9ea4047
commit f00adf6fce
6 changed files with 104 additions and 48 deletions

View File

@@ -24,6 +24,7 @@ import {
import type { CSSProperties, HTMLAttributes, KeyboardEvent, ReactNode } from 'react'; import type { CSSProperties, HTMLAttributes, KeyboardEvent, ReactNode } from 'react';
import { Fragment, memo, useCallback, useMemo, useRef, useState } from 'react'; import { Fragment, memo, useCallback, useMemo, useRef, useState } from 'react';
import { showGraphQLDocExplorerAtom } from '../atoms/graphqlSchemaAtom'; import { showGraphQLDocExplorerAtom } from '../atoms/graphqlSchemaAtom';
import { useContainerSize } from '../hooks/useContainerQuery';
import { useDebouncedValue } from '../hooks/useDebouncedValue'; import { useDebouncedValue } from '../hooks/useDebouncedValue';
import { useRandomKey } from '../hooks/useRandomKey'; import { useRandomKey } from '../hooks/useRandomKey';
import { useStateWithDeps } from '../hooks/useStateWithDeps'; import { useStateWithDeps } from '../hooks/useStateWithDeps';
@@ -61,11 +62,18 @@ export const GraphQLDocsExplorer = memo(function GraphQLDocsExplorer({
const mutItem: ExplorerItem = mutType ? { kind: 'type', type: mutType, from: null } : null; const mutItem: ExplorerItem = mutType ? { kind: 'type', type: mutType, from: null } : null;
const subItem: ExplorerItem = subType ? { kind: 'type', type: subType, from: null } : null; const subItem: ExplorerItem = subType ? { kind: 'type', type: subType, from: null } : null;
const allTypes = schema.getTypeMap(); const allTypes = schema.getTypeMap();
const containerRef = useRef<HTMLDivElement>(null);
const containerSize = useContainerSize(containerRef);
return ( return (
<div className={classNames(className, 'py-3 mx-3')} style={style}> <div ref={containerRef} className={classNames(className, 'py-3 mx-3')} style={style}>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full border border-dashed border-border rounded-lg"> <div className="grid grid-rows-[auto_minmax(0,1fr)] h-full border border-dashed border-border rounded-lg overflow-hidden">
<GraphQLExplorerHeader item={activeItem} setItem={setActiveItem} schema={schema} /> <GraphQLExplorerHeader
containerHeight={containerSize.height}
item={activeItem}
setItem={setActiveItem}
schema={schema}
/>
{activeItem == null ? ( {activeItem == null ? (
<div className="flex flex-col gap-3 overflow-y-auto h-full w-full px-3 pb-6"> <div className="flex flex-col gap-3 overflow-y-auto h-full w-full px-3 pb-6">
<Heading>Root Types</Heading> <Heading>Root Types</Heading>
@@ -120,10 +128,12 @@ function GraphQLExplorerHeader({
item, item,
setItem, setItem,
schema, schema,
containerHeight,
}: { }: {
item: ExplorerItem; item: ExplorerItem;
setItem: (t: ExplorerItem) => void; setItem: (t: ExplorerItem) => void;
schema: GraphQLSchema; schema: GraphQLSchema;
containerHeight: number;
}) { }) {
const findIt = (t: ExplorerItem): ExplorerItem[] => { const findIt = (t: ExplorerItem): ExplorerItem[] => {
if (t == null) return [null]; if (t == null) return [null];
@@ -131,28 +141,37 @@ function GraphQLExplorerHeader({
}; };
const crumbs = findIt(item); const crumbs = findIt(item);
return ( return (
<nav className="relative pl-2 pr-1 h-lg grid grid-rows-1 grid-cols-[auto_minmax(0,1fr)_auto] items-center min-w-0 gap-1"> <nav className="pl-2 pr-1 h-lg grid grid-rows-1 grid-cols-[minmax(0,1fr)_auto] items-center min-w-0 gap-1">
<div className="mr-3 whitespace-nowrap flex items-center gap-2 hide-scrollbars text-text-subtle overflow-x-auto hide-scrollbars text-sm"> <div className="@container w-full relative pl-2 pr-1 h-lg grid grid-rows-1 grid-cols-[minmax(0,min-content)_auto] items-center gap-1">
<Icon icon="book_open_text" /> <div className="whitespace-nowrap flex items-center gap-2 text-text-subtle text-sm overflow-x-auto hide-scrollbars">
{crumbs.map((crumb, i) => { <Icon icon="book_open_text" />
return ( {crumbs.map((crumb, i) => {
<Fragment key={i}> return (
{i > 0 && <Icon icon="chevron_right" className="text-text-subtlest" />} <Fragment key={i}>
{crumb === item || item == null ? ( {i > 0 && <Icon icon="chevron_right" className="text-text-subtlest" />}
<GqlTypeLabel item={item} /> {crumb === item || item == null ? (
) : crumb === item ? null : ( <GqlTypeLabel noTruncate item={item} />
<GqlTypeLink ) : crumb === item ? null : (
key={i} <GqlTypeLink
item={crumb} key={i}
setItem={setItem} noTruncate
className="!font-sans !text-sm" item={crumb}
/> setItem={setItem}
)} className="!font-sans !text-sm flex-shrink-0"
</Fragment> />
); )}
})} </Fragment>
);
})}
</div>
<GqlSchemaSearch
maxHeight={containerHeight}
currentItem={item}
schema={schema}
setItem={(item) => setItem(item)}
className="hidden @[10rem]:block"
/>
</div> </div>
<GqlSchemaSearch currentItem={item} schema={schema} setItem={(item) => setItem(item)} />
<div className="ml-auto flex gap-1 [&>*]:text-text-subtle"> <div className="ml-auto flex gap-1 [&>*]:text-text-subtle">
<IconButton <IconButton
icon="x" icon="x"
@@ -255,7 +274,7 @@ function GqlTypeInfo({
{heading} {heading}
<Subheading>Values</Subheading> <Subheading>Values</Subheading>
{values.map((v) => ( {values.map((v) => (
<div key={v.name} className="my-4 font-mono text-editor _truncate"> <div key={v.name} className="my-4 font-mono text-editor truncate">
<span className="text-primary">{v.value}</span> <span className="text-primary">{v.value}</span>
<DocMarkdown>{v.description ?? null}</DocMarkdown> <DocMarkdown>{v.description ?? null}</DocMarkdown>
</div> </div>
@@ -475,6 +494,7 @@ function GqlTypeLink({
rightSlot, rightSlot,
onNavigate, onNavigate,
className, className,
noTruncate,
}: { }: {
item: ExplorerItem; item: ExplorerItem;
color?: Color; color?: Color;
@@ -484,6 +504,7 @@ function GqlTypeLink({
leftSlot?: ReactNode; leftSlot?: ReactNode;
rightSlot?: ReactNode; rightSlot?: ReactNode;
className?: string; className?: string;
noTruncate?: boolean;
}) { }) {
if (item?.kind === 'type' && isListType(item.type)) { if (item?.kind === 'type' && isListType(item.type)) {
return ( return (
@@ -524,7 +545,8 @@ function GqlTypeLink({
className, className,
'hover:underline text-left mr-auto gap-2 max-w-full', 'hover:underline text-left mr-auto gap-2 max-w-full',
'inline-flex items-center', 'inline-flex items-center',
'font-mono text-editor _truncate', 'font-mono text-editor',
!noTruncate && 'truncate',
color === 'danger' && 'text-danger', color === 'danger' && 'text-danger',
color === 'primary' && 'text-primary', color === 'primary' && 'text-primary',
color === 'success' && 'text-success', color === 'success' && 'text-success',
@@ -538,7 +560,9 @@ function GqlTypeLink({
}} }}
> >
{leftSlot} {leftSlot}
<GqlTypeLabel item={item}>{children}</GqlTypeLabel> <GqlTypeLabel item={item} noTruncate={noTruncate}>
{children}
</GqlTypeLabel>
{rightSlot} {rightSlot}
</button> </button>
); );
@@ -548,10 +572,12 @@ function GqlTypeLabel({
item, item,
children, children,
className, className,
noTruncate,
}: { }: {
item: ExplorerItem; item: ExplorerItem;
children?: ReactNode; children?: ReactNode;
className?: string; className?: string;
noTruncate?: boolean;
}) { }) {
let inner; let inner;
if (children) { if (children) {
@@ -567,7 +593,7 @@ function GqlTypeLabel({
inner = 'UNKNOWN'; inner = 'UNKNOWN';
} }
return <span className={classNames(className, 'truncate')}>{inner}</span>; return <span className={classNames(className, !noTruncate && 'truncate')}>{inner}</span>;
} }
function Subheading({ children, count }: { children: ReactNode; count?: number }) { function Subheading({ children, count }: { children: ReactNode; count?: number }) {
@@ -592,11 +618,13 @@ function GqlSchemaSearch({
currentItem, currentItem,
setItem, setItem,
className, className,
maxHeight,
}: { }: {
currentItem: ExplorerItem | null; currentItem: ExplorerItem | null;
schema: GraphQLSchema; schema: GraphQLSchema;
setItem: (t: ExplorerItem) => void; setItem: (t: ExplorerItem) => void;
className?: string; className?: string;
maxHeight: number;
}) { }) {
const [activeResult, setActiveResult] = useStateWithDeps<SearchResult | null>(null, [ const [activeResult, setActiveResult] = useStateWithDeps<SearchResult | null>(null, [
currentItem, currentItem,
@@ -686,15 +714,15 @@ function GqlSchemaSearch({
[results, activeIndex, setActiveResult, activeResult, setItem, currentItem], [results, activeIndex, setActiveResult, activeResult, setItem, currentItem],
); );
if (!canSearch) return null; if (!canSearch) return <span />;
return ( return (
<div <div
className={classNames( className={classNames(
className, className,
'relative flex items-center bg-surface z-20', 'relative flex items-center bg-surface z-20 min-w-0',
!focused && 'w-[6rem] ml-auto', !focused && 'max-w-[6rem] ml-auto',
focused && '!absolute top-0 left-1.5 right-1.5 bottom-0', focused && '!absolute top-0 left-1.5 right-1.5 bottom-0 pt-1.5',
)} )}
> >
<PlainInput <PlainInput
@@ -722,9 +750,10 @@ function GqlSchemaSearch({
}} }}
/> />
<div <div
style={{ maxHeight: maxHeight - 60}}
className={classNames( className={classNames(
'x-theme-menu absolute z-10 mt-0.5 p-1.5 top-full right-0 bg-surface', 'x-theme-menu absolute z-10 mt-0.5 p-1.5 top-full right-0 bg-surface',
'border border-border rounded-lg overflow-y-auto min-w-[20rem] max-h-[20rem] w-full shadow-lg', 'border border-border rounded-lg overflow-y-auto min-w-[20rem] w-full shadow-lg',
!focused && 'hidden', !focused && 'hidden',
)} )}
> >
@@ -752,7 +781,7 @@ function GqlSchemaSearch({
. .
</> </>
)} )}
<GqlTypeLabel item={item} className="text-notice" /> <GqlTypeLabel item={item} className="text-text" />
</SearchResult> </SearchResult>
); );
})} })}
@@ -769,11 +798,21 @@ function SearchResult({
isActive: boolean; isActive: boolean;
children: ReactNode; children: ReactNode;
} & HTMLAttributes<HTMLButtonElement>) { } & HTMLAttributes<HTMLButtonElement>) {
const initRef = useCallback(
(el: HTMLButtonElement | null) => {
if (el === null) return;
if (isActive) {
el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
}
},
[isActive],
);
return ( return (
<button <button
ref={initRef}
className={classNames( className={classNames(
className, className,
'px-3 truncate w-full text-left h-sm rounded', 'px-3 truncate w-full text-left h-sm rounded text-editor font-mono',
isActive && 'bg-surface-highlight', isActive && 'bg-surface-highlight',
)} )}
{...extraProps} {...extraProps}

View File

@@ -12,6 +12,7 @@ import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { showDialog } from '../lib/dialog'; import { showDialog } from '../lib/dialog';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
import { Button } from './core/Button'; import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown'; import { Dropdown } from './core/Dropdown';
import type { EditorProps } from './core/Editor/Editor'; import type { EditorProps } from './core/Editor/Editor';
import { Editor } from './core/Editor/Editor'; import { Editor } from './core/Editor/Editor';
@@ -74,6 +75,17 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
{schema === undefined ? null /* Initializing */ : ( {schema === undefined ? null /* Initializing */ : (
<Dropdown <Dropdown
items={[ items={[
...((schema != null
? [
{
label: 'Clear',
onSelect: clear,
color: 'danger',
leftSlot: <Icon icon="trash" />,
},
{ type: 'separator' },
]
: []) satisfies DropdownItem[]),
{ {
hidden: !error, hidden: !error,
label: ( label: (
@@ -116,6 +128,7 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
type: 'content', type: 'content',
}, },
{ {
hidden: schema == null,
label: `${isDocOpen ? 'Hide' : 'Show'} Documentation`, label: `${isDocOpen ? 'Hide' : 'Show'} Documentation`,
leftSlot: <Icon icon="book_open_text" />, leftSlot: <Icon icon="book_open_text" />,
onSelect: () => { onSelect: () => {
@@ -124,16 +137,10 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
}, },
{ {
label: 'Introspect Schema', label: 'Introspect Schema',
leftSlot: <Icon icon="refresh" />, leftSlot: <Icon icon="refresh" spin={isLoading} />,
keepOpenOnSelect: true,
onSelect: refetch, onSelect: refetch,
}, },
{
label: 'Clear',
onSelect: clear,
hidden: !schema,
color: 'danger',
leftSlot: <Icon icon="trash" />,
},
{ type: 'separator', label: 'Setting' }, { type: 'separator', label: 'Setting' },
{ {
label: 'Automatic Introspection', label: 'Automatic Introspection',

View File

@@ -43,10 +43,15 @@ export function HttpRequestLayout({ activeRequest, style }: Props) {
return ( return (
<SplitLayout <SplitLayout
name="graphql_layout" name="graphql_layout"
defaultRatio={0.25} defaultRatio={1/3}
firstSlot={requestResponseSplit} firstSlot={requestResponseSplit}
secondSlot={({ style, orientation }) => ( secondSlot={({ style, orientation }) => (
<GraphQLDocsExplorer key={activeRequest.id} schema={graphQLSchema} className={classNames(orientation == 'horizontal' && '!ml-0')} style={style} /> <GraphQLDocsExplorer
key={activeRequest.id}
schema={graphQLSchema}
className={classNames(orientation == 'horizontal' && '!ml-0')}
style={style}
/>
)} )}
/> />
); );

View File

@@ -129,7 +129,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
</div> </div>
{rightSlot && <div className="ml-1">{rightSlot}</div>} {rightSlot && <div className="ml-1">{rightSlot}</div>}
{forDropdown && ( {forDropdown && (
<Icon icon="chevron_down" size={size === 'auto' ? 'md' : size} className="ml-1 -mr-1" /> <Icon
icon="chevron_down"
size={size === 'auto' ? 'md' : size}
className="ml-1 -mr-1 relative top-[0.1em]"
/>
)} )}
</button> </button>
); );

View File

@@ -62,6 +62,7 @@ export type DropdownItemDefault = {
leftSlot?: ReactNode; leftSlot?: ReactNode;
rightSlot?: ReactNode; rightSlot?: ReactNode;
waitForOnSelect?: boolean; waitForOnSelect?: boolean;
keepOpenOnSelect?: boolean;
onSelect?: () => void | Promise<void>; onSelect?: () => void | Promise<void>;
}; };
@@ -402,7 +403,7 @@ const Menu = forwardRef<Omit<DropdownRef, 'open' | 'isOpen' | 'toggle' | 'items'
} }
} }
handleClose(); if (!item.keepOpenOnSelect) handleClose();
}, },
[handleClose, setSelectedIndex], [handleClose, setSelectedIndex],
); );

View File

@@ -110,7 +110,7 @@ export function useIntrospectGraphQL(
}, [upsertIntrospection]); }, [upsertIntrospection]);
useEffect(() => { useEffect(() => {
if (introspection.data?.content == null) { if (introspection.data?.content == null || introspection.data.content === '') {
return; return;
} }