diff --git a/.changeset/activity-rail.md b/.changeset/activity-rail.md new file mode 100644 index 00000000000..797ea3b92f7 --- /dev/null +++ b/.changeset/activity-rail.md @@ -0,0 +1,8 @@ +--- +'@graphiql/react': minor +'graphiql': major +--- + +Replace the left-side plugin sidebar with `ActivityRail`. Plugins render as a flat list in registration order with a 2px blue left border on the active plugin. Settings gear at the bottom opens the settings dialog. + +The previous `graphiql-sidebar` DOM structure and CSS class names are removed. Custom CSS overrides targeting `.graphiql-sidebar` or its children will need updating. Only CSS variable names are part of the public API. diff --git a/.changeset/doc-explorer-redesign.md b/.changeset/doc-explorer-redesign.md new file mode 100644 index 00000000000..52a34b662ef --- /dev/null +++ b/.changeset/doc-explorer-redesign.md @@ -0,0 +1,5 @@ +--- +'@graphiql/plugin-doc-explorer': minor +--- + +Redesign the Schema Explorer panel: eyebrow header with filter and search icon buttons, dedicated breadcrumb row with color-coded depth segments, inline search row with keycap hint, type card with TYPE badge and implements list, and a mono field list with type colors and active-row accent border. diff --git a/.changeset/history-method-pill.md b/.changeset/history-method-pill.md new file mode 100644 index 00000000000..29cc9dfa7fa --- /dev/null +++ b/.changeset/history-method-pill.md @@ -0,0 +1,6 @@ +--- +'@graphiql/plugin-history': patch +'@graphiql/toolkit': patch +--- + +Show a `MethodPill` (`QRY`/`MUT`/`SUB`) on each History row in place of the green status dot. `QueryStoreItem` gains an optional `operation` field, populated at write time from the parsed query. The Clear button no longer flashes green on success. diff --git a/.changeset/keycap-hint-os-detection.md b/.changeset/keycap-hint-os-detection.md new file mode 100644 index 00000000000..1bb7f7544fe --- /dev/null +++ b/.changeset/keycap-hint-os-detection.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +`KeycapHint` now takes semantic modifier names via the new `MODIFIER` constant. `MODIFIER.Meta` renders as `⌘` on macOS and `Ctrl` elsewhere; `Ctrl`/`Alt`/`Shift` render as Mac glyphs (`⌃`/`⌥`/`⇧`) on macOS and as plain text on other platforms. `Enter` renders as `⏎` on every platform. diff --git a/.changeset/keycap-hint.md b/.changeset/keycap-hint.md new file mode 100644 index 00000000000..ddd595ec132 --- /dev/null +++ b/.changeset/keycap-hint.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +Add a `KeycapHint` primitive for displaying inline keyboard shortcuts (e.g. `⌘K`, `⌘⏎`). Used in the new top bar; available for general consumer use. diff --git a/.changeset/method-pill.md b/.changeset/method-pill.md new file mode 100644 index 00000000000..e5697f77539 --- /dev/null +++ b/.changeset/method-pill.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +Add a `MethodPill` primitive: a small colored pill labeling an operation as QRY (query), MUT (mutation), or SUB (subscription). diff --git a/.changeset/monaco-theme-v6.md b/.changeset/monaco-theme-v6.md new file mode 100644 index 00000000000..81c727ecd85 --- /dev/null +++ b/.changeset/monaco-theme-v6.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +Replace the Monaco editor theme with v6-aligned token colors. Both `graphiql-DARK` and `graphiql-LIGHT` now cover all GraphQL token types (keywords, type names, field identifiers, variables, annotations, strings, numbers, comments) using the v6 design's accent palette. UI chrome colors (suggest widget, hover widget, quick input) are updated to match. diff --git a/.changeset/oklch-tokens.md b/.changeset/oklch-tokens.md new file mode 100644 index 00000000000..6e268cf5ada --- /dev/null +++ b/.changeset/oklch-tokens.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +Introduce the v6 OKLCH design-token system with both dark and light theme palettes. Tokens (`--bg-canvas`, `--fg-default`, `--accent-blue`, etc.) are stored as OKLCH triplets so opacity can be combined at the call site. Themes are keyed off `data-theme` (`dark` is the default; `light` activates explicitly or via `prefers-color-scheme: light` when no override is set). Existing v5 variables are unchanged; component styles continue to use them until they are migrated. diff --git a/.changeset/panel-header.md b/.changeset/panel-header.md new file mode 100644 index 00000000000..413e45b1173 --- /dev/null +++ b/.changeset/panel-header.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +Add a `PanelHeader` primitive for side panels. Renders a title, optional subtitle, and optional action-icon row. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 00000000000..b27ac73cadd --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,29 @@ +{ + "mode": "pre", + "tag": "alpha", + "initialVersions": { + "example-graphiql-vite": "0.0.0", + "example-graphiql-vite-react-router": "1.0.0", + "example-graphiql-webpack": "0.0.0", + "example-monaco-graphql-nextjs": "0.0.0", + "example-monaco-graphql-react-vite": "0.0.0", + "example-monaco-graphql-webpack": "0.0.0", + "cm6-graphql": "0.2.1", + "codemirror-graphql": "2.2.4", + "graphiql": "5.2.2", + "@graphiql/plugin-code-exporter": "5.1.1", + "@graphiql/plugin-doc-explorer": "0.4.1", + "@graphiql/plugin-explorer": "5.1.1", + "@graphiql/plugin-history": "0.4.1", + "@graphiql/react": "0.37.3", + "@graphiql/toolkit": "0.11.3", + "graphql-language-service": "5.5.0", + "graphql-language-service-cli": "3.5.0", + "graphql-language-service-server": "2.14.8", + "monaco-graphql": "1.7.3", + "vscode-graphql": "0.13.2", + "vscode-graphql-execution": "0.3.2", + "vscode-graphql-syntax": "1.3.8" + }, + "changesets": [] +} diff --git a/.changeset/response-pane-header.md b/.changeset/response-pane-header.md new file mode 100644 index 00000000000..d40b01c3cda --- /dev/null +++ b/.changeset/response-pane-header.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +Add a response pane header showing status, elapsed time, response size, a JSON / Tree / Table view toggle, and a copy button. The selected view is persisted in storage and restored on reload. Tree and Table views render a placeholder until their implementations land. diff --git a/.changeset/response-tree-view.md b/.changeset/response-tree-view.md new file mode 100644 index 00000000000..6927e999104 --- /dev/null +++ b/.changeset/response-tree-view.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +Add `ResponseTreeView` component for the response pane's Tree view. Renders GraphQL response JSON as a collapsible tree with type-colored values. Top-level nodes expand by default; deeper levels are collapsed. Selecting "Tree" in the response pane view toggle now shows the tree instead of a placeholder. diff --git a/.changeset/restyle-button-iconbutton.md b/.changeset/restyle-button-iconbutton.md new file mode 100644 index 00000000000..9bd754c377f --- /dev/null +++ b/.changeset/restyle-button-iconbutton.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': patch +--- + +Restyle `Button` and `ToolbarButton` to the v6 OKLCH design tokens. Adds a `primary` variant to `Button` for the Run-button style. Storybook stories added for each variant. Props and behavior unchanged. diff --git a/.changeset/restyle-dialog.md b/.changeset/restyle-dialog.md new file mode 100644 index 00000000000..76b5b641c8e --- /dev/null +++ b/.changeset/restyle-dialog.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': patch +--- + +Restyle `Dialog` to the v6 design: `--bg-elevated` surface, refined border, larger radius. Behavior and API unchanged. diff --git a/.changeset/restyle-doc-explorer.md b/.changeset/restyle-doc-explorer.md new file mode 100644 index 00000000000..7affd764f3f --- /dev/null +++ b/.changeset/restyle-doc-explorer.md @@ -0,0 +1,5 @@ +--- +'@graphiql/plugin-doc-explorer': patch +--- + +Restyle to the v6 design. Uses the new `PanelHeader` chrome and OKLCH design tokens. Type names render in `--accent-orange`, field and enum names in `--accent-green-light`, argument names in `--accent-purple`. diff --git a/.changeset/restyle-dropdown-menu.md b/.changeset/restyle-dropdown-menu.md new file mode 100644 index 00000000000..ad9b31e0ba4 --- /dev/null +++ b/.changeset/restyle-dropdown-menu.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': patch +--- + +Restyle `DropdownMenu` to the v6 design. Behavior and API unchanged. diff --git a/.changeset/restyle-editortabs.md b/.changeset/restyle-editortabs.md new file mode 100644 index 00000000000..7e99f4f862e --- /dev/null +++ b/.changeset/restyle-editortabs.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +Restyle the editor tabs to the v6 design. Tabs now show a dirty-state dot when unsaved changes are present and a hover-only close affordance per tab. Adds a `lastSavedQuery` field to the tab store; the dirty state is computed from the diff against that snapshot. Adds a `saveQuery` action (keybind `Ctrl-S` / `Cmd-S`) that updates the snapshot. Adds prettify, copy, and save buttons to the right side of the tab strip. diff --git a/.changeset/restyle-history.md b/.changeset/restyle-history.md new file mode 100644 index 00000000000..13c41127c8d --- /dev/null +++ b/.changeset/restyle-history.md @@ -0,0 +1,5 @@ +--- +'@graphiql/plugin-history': patch +--- + +Restyle to the v6 design. New row layout with status dot, mono name label, and inline variables snippet. Uses `PanelHeader` for the panel chrome. diff --git a/.changeset/restyle-spinner.md b/.changeset/restyle-spinner.md new file mode 100644 index 00000000000..ca2c12bf27b --- /dev/null +++ b/.changeset/restyle-spinner.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': patch +--- + +Restyle `Spinner` to the v6 design. Default stroke uses the muted foreground token. diff --git a/.changeset/restyle-tabs.md b/.changeset/restyle-tabs.md new file mode 100644 index 00000000000..cc4f30decc4 --- /dev/null +++ b/.changeset/restyle-tabs.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': patch +--- + +Restyle `Tabs` primitive to the v6 design. Active state uses a top accent border bleeding into the strip's bottom border. diff --git a/.changeset/restyle-tooltip.md b/.changeset/restyle-tooltip.md new file mode 100644 index 00000000000..eec9ab308e0 --- /dev/null +++ b/.changeset/restyle-tooltip.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': patch +--- + +Restyle `Tooltip` to the v6 design: `--bg-elevated` surface, simplified border and shadow. diff --git a/.changeset/segmented-control-radio.md b/.changeset/segmented-control-radio.md new file mode 100644 index 00000000000..20dbbc001d9 --- /dev/null +++ b/.changeset/segmented-control-radio.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': patch +--- + +Rewrite `SegmentedControl` on top of native radio inputs. Public props are unchanged. Keyboard navigation (arrow keys, Home / End) and screen-reader semantics now come from the browser; the group is a single tab stop instead of one per option. diff --git a/.changeset/segmented-control.md b/.changeset/segmented-control.md new file mode 100644 index 00000000000..ec2c3b0978c --- /dev/null +++ b/.changeset/segmented-control.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +Add a `SegmentedControl` primitive for selecting one option from a small set inline. Used by the new top-bar response view toggle and several settings controls. diff --git a/.changeset/side-panel.md b/.changeset/side-panel.md new file mode 100644 index 00000000000..1bdbedf0aab --- /dev/null +++ b/.changeset/side-panel.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +Add `SidePanel` component that hosts the active plugin's content next to the activity rail. Default width 340px, resizable. diff --git a/.changeset/status-bar.md b/.changeset/status-bar.md new file mode 100644 index 00000000000..3b40337747e --- /dev/null +++ b/.changeset/status-bar.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +Add a `StatusBar` layout component: a 24px-tall footer at the bottom of the app showing connection status, schema type count, active plugin count, cursor position, and document metadata (encoding, indent, language label). diff --git a/.changeset/theme-data-attribute.md b/.changeset/theme-data-attribute.md new file mode 100644 index 00000000000..50b590de76a --- /dev/null +++ b/.changeset/theme-data-attribute.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': patch +--- + +`setTheme` now mirrors the chosen theme onto `document.documentElement` as a `data-theme` attribute. The v6 OKLCH token cascade in `tokens.css` is gated on `[data-theme='light']` / `[data-theme='dark']` selectors and was previously never matched, so v6 components fell back to the OS `prefers-color-scheme` regardless of the GraphiQL theme toggle. The existing `body.graphiql-light` / `body.graphiql-dark` classes are preserved for backwards compatibility with custom CSS. diff --git a/.changeset/topbar-component.md b/.changeset/topbar-component.md new file mode 100644 index 00000000000..000dbd8b61d --- /dev/null +++ b/.changeset/topbar-component.md @@ -0,0 +1,6 @@ +--- +'@graphiql/react': minor +'graphiql': patch +--- + +Add a new `TopBar` layout component with brand, endpoint URL display, command palette button, and primary Run button. Endpoint method/URL are placeholders until the transport API lands. `TopBar` is now mounted at the top of the GraphiQL layout. diff --git a/.changeset/transport-api.md b/.changeset/transport-api.md new file mode 100644 index 00000000000..ab4b924913f --- /dev/null +++ b/.changeset/transport-api.md @@ -0,0 +1,9 @@ +--- +'@graphiql/toolkit': minor +'@graphiql/react': minor +'graphiql': minor +--- + +Add a `Transport` API alongside the existing `Fetcher`. `createTransport({...})` performs the GraphQL request and returns a `TransportResponse` carrying the real HTTP wire metadata (status, headers, timing, size) for queries, mutations, subscriptions, and incremental delivery. `` accepts a new `transport` prop, mutually exclusive with `fetcher` at the type level, that lets the response pane surface those values directly from the underlying `Response`. Subscriptions require an explicit `subscriptionClient` (any client whose `.subscribe(payload, sink)` matches the `graphql-ws` `Client` shape, including `graphql-sse`'s `createClient()`); the toolkit no longer constructs one. The CDN bundle exposes `GraphiQL.createTransport` and `GraphiQL.createWsClient` so script-tag consumers can adopt without a bundler. + +`createGraphiQLFetcher`, the `Fetcher` type and its companions, and the `` prop are deprecated but continue to work unchanged. Existing code keeps compiling. Consumers on the deprecated path see a one-time dismissible banner in the response pane pointing at `docs/migration/graphiql-6.0.0.md` rather than fabricated status/timing/size values. diff --git a/.changeset/use-graphiql-settings.md b/.changeset/use-graphiql-settings.md new file mode 100644 index 00000000000..10733a5d9f1 --- /dev/null +++ b/.changeset/use-graphiql-settings.md @@ -0,0 +1,5 @@ +--- +'@graphiql/react': minor +--- + +Add a `useGraphiQLSettings()` hook for managing theme / density / font-size preferences with `localStorage` persistence. Settings apply automatically to the GraphiQL container via `data-*` attributes. diff --git a/.changeset/v6-alpha-line.md b/.changeset/v6-alpha-line.md new file mode 100644 index 00000000000..751f5642634 --- /dev/null +++ b/.changeset/v6-alpha-line.md @@ -0,0 +1,5 @@ +--- +'graphiql': major +--- + +Initial v6 alpha. Refs graphql/graphiql#4219. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b519ea8d725..f75d7aec5b1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -76,6 +76,7 @@ jobs: with: key: build-${{ github.sha }} path: ${{ env.BUILD-CACHE-LIST }} + - run: npx playwright install --with-deps chromium - run: yarn test lint: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31b9848070f..84281db92e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,7 @@ on: branches: - main - release/* + - graphiql-6 concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.gitignore b/.gitignore index 8f0b981fff2..38f3fb5120e 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,5 @@ packages/graphiql/typedoc/ packages/graphiql/webpack/ .react-router/ + +storybook-static/ diff --git a/docs/migration/graphiql-6.0.0.md b/docs/migration/graphiql-6.0.0.md new file mode 100644 index 00000000000..032a01732e5 --- /dev/null +++ b/docs/migration/graphiql-6.0.0.md @@ -0,0 +1,160 @@ +# Upgrading `graphiql` to `6.0.0` + +This covers the one notable change in `graphiql@6`: a new `Transport` API that replaces `Fetcher`/`createGraphiQLFetcher`. The rest of the surface carries over. Open an issue if something is missing here and we'll add it. + +## Overview + +`@graphiql/toolkit` adds `createTransport`, which takes the same options as `createGraphiQLFetcher` and produces a `Transport`. Unlike `Fetcher`, a `Transport`'s response carries the real HTTP wire data: status code, headers, body, timing, and request/response sizes. `` accepts a new `transport` prop alongside the existing `fetcher` prop; the two are mutually exclusive at the type level. The old API still works. + +## `createGraphiQLFetcher` → `createTransport` + +The HTTP options object carries over. Subscriptions are different: `createTransport` does not build a subscription client for you. You construct your own `graphql-ws` (or `graphql-sse`) client and pass it as `subscriptionClient`. There is no `subscriptionUrl`, `wsClient`, `legacyClient`, or `wsConnectionParams` option. + +**Before:** + +```ts +import { createGraphiQLFetcher } from '@graphiql/toolkit'; + +const fetcher = createGraphiQLFetcher({ + url: 'https://my.endpoint/graphql', + subscriptionUrl: 'wss://my.endpoint/graphql', +}); +``` + +**After (WebSocket subscriptions):** + +```ts +import { createClient } from 'graphql-ws'; +import { createTransport } from '@graphiql/toolkit'; + +const transport = createTransport({ + url: 'https://my.endpoint/graphql', + subscriptionClient: createClient({ url: 'wss://my.endpoint/graphql' }), +}); +``` + +**After (SSE subscriptions):** + +`graphql-sse`'s `createClient()` is signature-compatible with `graphql-ws`, so the same option drives either protocol: + +```ts +import { createClient } from 'graphql-sse'; +import { createTransport } from '@graphiql/toolkit'; + +const transport = createTransport({ + url: 'https://my.endpoint/graphql', + subscriptionClient: createClient({ + url: 'https://my.endpoint/graphql/stream', + }), +}); +``` + +If you only run queries and mutations, leave `subscriptionClient` off. A subscription dispatched without it throws with a pointer back to this page. + +## `` → `` + +**Before:** + +```tsx +import { createGraphiQLFetcher } from '@graphiql/toolkit'; +import { GraphiQL } from 'graphiql'; + +const fetcher = createGraphiQLFetcher({ url: 'https://my.endpoint/graphql' }); + +function App() { + return ; +} +``` + +**After:** + +```tsx +import { createTransport } from '@graphiql/toolkit'; +import { GraphiQL } from 'graphiql'; + +const transport = createTransport({ url: 'https://my.endpoint/graphql' }); + +function App() { + return ; +} +``` + +Passing both props is a type error. Remove `fetcher` when you add `transport`. + +## What you get + +With a `Transport`, the response pane header shows the real HTTP status code, total round-trip time, and the request and response byte sizes, read directly off the `Response`. Response headers are accessible through the response detail panel. + +With a `fetcher`, none of that is observable. The `Fetcher` contract is `(params) => ExecutionResult`, which discards the HTTP envelope before GraphiQL sees it. The response pane shows a small dismissible notice pointing here instead of fabricated values. + +## Custom fetchers (FAQ) + +If you hand-rolled a `Fetcher` rather than using `createGraphiQLFetcher`, migrate by implementing the `Transport` interface directly. A `Transport` is an object with a `send(request)` method. For queries and mutations it returns a `Promise`; for subscriptions and incremental delivery it returns an `AsyncIterable`, one entry per event or chunk. + +**Before (custom fetcher):** + +```ts +import type { Fetcher } from '@graphiql/toolkit'; + +const fetcher: Fetcher = async params => { + const res = await fetch('/graphql', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(params), + }); + return res.json(); +}; +``` + +**After (custom transport):** + +```ts +import type { Transport } from '@graphiql/toolkit'; + +const transport: Transport = { + async send(request) { + const startMs = performance.now(); + const requestBody = JSON.stringify({ + query: request.query, + operationName: request.operationName, + variables: request.variables, + }); + const response = await fetch('/graphql', { + method: 'POST', + headers: { 'content-type': 'application/json', ...request.headers }, + body: requestBody, + }); + const body = await response.json(); + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + return { + ok: !body.errors?.length, + status: response.status, + statusText: response.statusText, + headers, + body, + timing: { totalMs: performance.now() - startMs }, + size: { + request: new TextEncoder().encode(requestBody).length, + response: new TextEncoder().encode(JSON.stringify(body)).length, + }, + }; + }, +}; +``` + +Read `status`, `statusText`, and `headers` off the real `Response`. Don't hard-code them; the response pane reads those fields directly. + +## What's deprecated, not removed + +The following are deprecated in `graphiql@6`. They continue to work and might be removed in a future major version. + +| Deprecated | Replacement | +| ------------------------------------------------ | ----------------- | +| `createGraphiQLFetcher` from `@graphiql/toolkit` | `createTransport` | +| `fetcher` prop on `` | `transport` prop | +| `Fetcher` type from `@graphiql/toolkit` | `Transport` | + +Existing code keeps compiling and running today. Migrate when you want the response pane to show real wire data, or before a future major version drops the deprecated path. diff --git a/examples/graphiql-cdn/index.html b/examples/graphiql-cdn/index.html index 2ed29a0ee97..3a6d0e1c35e 100644 --- a/examples/graphiql-cdn/index.html +++ b/examples/graphiql-cdn/index.html @@ -76,18 +76,18 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { GraphiQL, HISTORY_PLUGIN } from 'graphiql'; - import { createGraphiQLFetcher } from '@graphiql/toolkit'; + import { createTransport } from '@graphiql/toolkit'; import { explorerPlugin } from '@graphiql/plugin-explorer'; import 'graphiql/setup-workers/esm.sh'; - const fetcher = createGraphiQLFetcher({ + const transport = createTransport({ url: 'https://countries.trevorblades.com', }); const plugins = [HISTORY_PLUGIN, explorerPlugin()]; function App() { return React.createElement(GraphiQL, { - fetcher, + transport, plugins, defaultEditorToolsVisibility: true, }); diff --git a/examples/graphiql-nextjs/src/app/graphiql.tsx b/examples/graphiql-nextjs/src/app/graphiql.tsx index 9cb59806ff1..d7bcd498134 100644 --- a/examples/graphiql-nextjs/src/app/graphiql.tsx +++ b/examples/graphiql-nextjs/src/app/graphiql.tsx @@ -2,21 +2,14 @@ import type { FC } from 'react'; import { GraphiQL } from 'graphiql'; +import { createTransport } from '@graphiql/toolkit'; import 'graphiql/setup-workers/webpack'; import 'graphiql/style.css'; -async function fetcher(graphQLParams: Record) { - const response = await fetch('https://graphql.earthdata.nasa.gov/api', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(graphQLParams), - }); - return response.json(); -} +const transport = createTransport({ + url: 'https://graphql.earthdata.nasa.gov/api', +}); export const GraphiQLPage: FC = () => { - return ; + return ; }; diff --git a/examples/graphiql-vite-react-router/README.md b/examples/graphiql-vite-react-router/README.md index 60450da05e3..4ff74a75afe 100644 --- a/examples/graphiql-vite-react-router/README.md +++ b/examples/graphiql-vite-react-router/README.md @@ -7,11 +7,11 @@ by adding `.client` to the file name. ```tsx // graphiql.client.tsx import { GraphiQL } from 'graphiql'; -import { createGraphiQLFetcher } from '@graphiql/toolkit'; +import { createTransport } from '@graphiql/toolkit'; -const fetcher = createGraphiQLFetcher({ url: 'https://my.backend/graphql' }); +const transport = createTransport({ url: 'https://my.backend/graphql' }); -export const graphiql = ; +export const graphiql = ; ``` ```ts diff --git a/examples/graphiql-vite-react-router/app/routes/_index/create-fetcher.ts b/examples/graphiql-vite-react-router/app/routes/_index/create-fetcher.ts deleted file mode 100644 index 1c095133685..00000000000 --- a/examples/graphiql-vite-react-router/app/routes/_index/create-fetcher.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { GraphiQLProps } from 'graphiql'; - -export function createFetcher(apiUrl: string): GraphiQLProps['fetcher'] { - return async function (graphQLParams, opts) { - const response = await fetch(apiUrl, { - method: 'POST', - headers: { - ...opts?.headers, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(graphQLParams), - }); - - return response.json(); - }; -} diff --git a/examples/graphiql-vite-react-router/app/routes/_index/graphiql.client.tsx b/examples/graphiql-vite-react-router/app/routes/_index/graphiql.client.tsx index b435650134d..f50c5f3fa0f 100644 --- a/examples/graphiql-vite-react-router/app/routes/_index/graphiql.client.tsx +++ b/examples/graphiql-vite-react-router/app/routes/_index/graphiql.client.tsx @@ -1,14 +1,18 @@ import type { FC } from 'react'; import { GraphiQL } from 'graphiql'; +import { createTransport } from '@graphiql/toolkit'; import { ToolbarButton, useGraphiQL } from '@graphiql/react'; -import { createFetcher } from './create-fetcher'; import 'graphiql/setup-workers/esm.sh'; +const transport = createTransport({ + url: 'https://graphql.earthdata.nasa.gov/api', +}); + export const graphiql = ( API Explorer diff --git a/examples/graphiql-vite/src/App.jsx b/examples/graphiql-vite/src/App.jsx index 36a1683d5f4..1a8b71e5a90 100644 --- a/examples/graphiql-vite/src/App.jsx +++ b/examples/graphiql-vite/src/App.jsx @@ -1,20 +1,13 @@ import { GraphiQL } from 'graphiql'; +import { createTransport } from '@graphiql/toolkit'; import 'graphiql/style.css'; -async function fetcher(graphQLParams) { - const response = await fetch('https://graphql.earthdata.nasa.gov/api', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(graphQLParams), - }); - return response.json(); -} +const transport = createTransport({ + url: 'https://graphql.earthdata.nasa.gov/api', +}); function App() { - return ; + return ; } export default App; diff --git a/examples/graphiql-webpack/src/index.jsx b/examples/graphiql-webpack/src/index.jsx index 8ca47fadc8d..6c93e9338d1 100644 --- a/examples/graphiql-webpack/src/index.jsx +++ b/examples/graphiql-webpack/src/index.jsx @@ -5,7 +5,7 @@ import { GraphiQL } from 'graphiql'; import { explorerPlugin } from '@graphiql/plugin-explorer'; import { getSnippets } from './snippets'; import { codeExporterPlugin } from '@graphiql/plugin-code-exporter'; -import { createGraphiQLFetcher } from '@graphiql/toolkit'; +import { createTransport } from '@graphiql/toolkit'; import { useGraphiQL } from '@graphiql/react'; import { serverSelectPlugin, LAST_URL_KEY } from './select-server-plugin'; import 'graphiql/setup-workers/webpack'; @@ -65,8 +65,8 @@ function App() { codeExporterPlugin({ snippets: getSnippets({ serverUrl: currentUrl }) }), [currentUrl], ); - const fetcher = useMemo( - () => createGraphiQLFetcher({ url: currentUrl }), + const transport = useMemo( + () => createTransport({ url: currentUrl }), [currentUrl], ); const serverSelect = useMemo( @@ -78,7 +78,7 @@ function App() { diff --git a/examples/monaco-graphql-nextjs/src/editor.tsx b/examples/monaco-graphql-nextjs/src/editor.tsx index 32c2ecd8976..669b580a7f0 100644 --- a/examples/monaco-graphql-nextjs/src/editor.tsx +++ b/examples/monaco-graphql-nextjs/src/editor.tsx @@ -8,7 +8,7 @@ import 'monaco-editor/esm/vs/editor/contrib/peekView/browser/peekView'; import 'monaco-editor/esm/vs/editor/contrib/parameterHints/browser/parameterHints'; import 'monaco-editor/esm/vs/language/typescript/monaco.contribution'; -import { createGraphiQLFetcher } from '@graphiql/toolkit'; +import { createTransport, type TransportResponse } from '@graphiql/toolkit'; import * as JSONC from 'jsonc-parser'; import { DEFAULT_EDITOR_OPTIONS, @@ -24,15 +24,23 @@ import { getOrCreateModel, } from './constants'; -const fetcher = createGraphiQLFetcher({ url: GRAPHQL_URL }); +// This demo only handles single-response query/mutation, so disable incremental +// delivery to keep `transport.send()` resolving a single `TransportResponse`. +const transport = createTransport({ + url: GRAPHQL_URL, + enableIncrementalDelivery: false, +}); async function getSchema(): Promise { - const data = await fetcher({ + const response = (await transport.send({ query: getIntrospectionQuery(), operationName: 'IntrospectionQuery', - }); + })) as TransportResponse; + const body = response.body; const introspectionJSON = - 'data' in data && (data.data as unknown as IntrospectionQuery); + !Array.isArray(body) && 'data' in body + ? (body.data as unknown as IntrospectionQuery) + : undefined; if (!introspectionJSON) { throw new Error( @@ -116,15 +124,11 @@ export default function Editor(): ReactElement { // eslint-disable-next-line no-bitwise keybindings: [KeyMod.CtrlCmd | KeyCode.Enter], async run() { - const result = await fetcher({ + const response = (await transport.send({ query: MODEL.operations.getValue(), variables: JSONC.parse(MODEL.variables.getValue()), - }); - // TODO: this demo only supports a single iteration for http GET/POST, - // no multipart or subscriptions yet. - // @ts-expect-error - const data = await result.next(); - MODEL.response.setValue(JSON.stringify(data.value, null, 2)); + })) as TransportResponse; + MODEL.response.setValue(JSON.stringify(response.body, null, 2)); }, }; diff --git a/package.json b/package.json index 9c958ec8b94..1c434cdc5aa 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "dev:graphiql": "turbo run dev --filter=graphiql...", "dev:example-nextjs": "turbo run dev --filter=example-graphiql-nextjs...", "dev:example-vite": "turbo run dev --filter=example-graphiql-vite...", + "storybook": "yarn workspace @graphiql/react storybook", + "build-storybook": "yarn workspace @graphiql/react build-storybook", "build:graphiql": "turbo run build --filter=graphiql...", "build": "yarn build-clean && yarn build:tsc && yarn build:nontsc", "build-bundles": "yarn prebuild-bundles && yarn wsrun:noexamples --stages build-bundles", diff --git a/packages/graphiql-plugin-code-exporter/README.md b/packages/graphiql-plugin-code-exporter/README.md index 56c683531e3..36843eb1358 100644 --- a/packages/graphiql-plugin-code-exporter/README.md +++ b/packages/graphiql-plugin-code-exporter/README.md @@ -26,12 +26,12 @@ Example integration: ```jsx import { GraphiQL } from 'graphiql'; -import { createGraphiQLFetcher } from '@graphiql/toolkit'; +import { createTransport } from '@graphiql/toolkit'; import { codeExporterPlugin } from '@graphiql/plugin-code-exporter'; import 'graphiql/style.css'; import '@graphiql/plugin-code-exporter/style.css'; -const fetcher = createGraphiQLFetcher({ +const transport = createTransport({ url: 'https://countries.trevorblades.com', }); function getQuery(arg, spaceCount = 2) { @@ -70,7 +70,7 @@ const codeExporter = codeExporterPlugin({ ], }); function App() { - return ; + return ; } ``` diff --git a/packages/graphiql-plugin-code-exporter/example/index.html b/packages/graphiql-plugin-code-exporter/example/index.html index 7cca73d0881..880064007eb 100644 --- a/packages/graphiql-plugin-code-exporter/example/index.html +++ b/packages/graphiql-plugin-code-exporter/example/index.html @@ -59,7 +59,7 @@ import ReactDOM from 'react-dom/client'; // Import GraphiQL and the Explorer plugin import { GraphiQL, HISTORY_PLUGIN } from 'graphiql'; - import { createGraphiQLFetcher } from '@graphiql/toolkit'; + import { createTransport } from '@graphiql/toolkit'; // Required to be before `@graphiql/plugin-code-exporter` import 'regenerator-runtime/runtime'; import { codeExporterPlugin } from '@graphiql/plugin-code-exporter'; @@ -81,7 +81,7 @@ }, }; - const fetcher = createGraphiQLFetcher({ + const transport = createTransport({ url: 'https://countries.trevorblades.com', }); function getQuery(arg, spaceCount = 2) { @@ -124,7 +124,7 @@ function App() { return React.createElement(GraphiQL, { - fetcher, + transport, plugins, defaultEditorToolsVisibility: true, }); diff --git a/packages/graphiql-plugin-doc-explorer/.storybook/main.ts b/packages/graphiql-plugin-doc-explorer/.storybook/main.ts new file mode 100644 index 00000000000..dddd2d457b7 --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/.storybook/main.ts @@ -0,0 +1,9 @@ +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(ts|tsx)'], + addons: ['@storybook/addon-a11y', '@storybook/addon-vitest'], + framework: { name: '@storybook/react-vite', options: {} }, +}; + +export default config; diff --git a/packages/graphiql-plugin-doc-explorer/.storybook/preview.tsx b/packages/graphiql-plugin-doc-explorer/.storybook/preview.tsx new file mode 100644 index 00000000000..6d1e00e82ee --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/.storybook/preview.tsx @@ -0,0 +1,52 @@ +import { useEffect } from 'react'; +import type { Preview } from '@storybook/react-vite'; +import '@graphiql/react/style.css'; + +const preview: Preview = { + parameters: { + backgrounds: { disable: true }, + a11y: { test: 'error' }, + }, + globalTypes: { + theme: { + description: 'Theme', + defaultValue: 'dark', + toolbar: { + icon: 'circlehollow', + items: [ + { value: 'dark', title: 'Dark' }, + { value: 'light', title: 'Light' }, + ], + dynamicTitle: true, + }, + }, + }, + decorators: [ + (Story, ctx) => { + useEffect(() => { + const root = document.documentElement; + root.setAttribute('data-theme', ctx.globals.theme); + document.body.style.background = 'oklch(var(--bg-canvas))'; + document.body.style.color = 'oklch(var(--fg-default))'; + document.body.style.margin = '0'; + }, [ctx.globals.theme]); + + return ( +
+ +
+ ); + }, + ], +}; + +export default preview; diff --git a/packages/graphiql-plugin-doc-explorer/package.json b/packages/graphiql-plugin-doc-explorer/package.json index 35535291ff6..24571da8d11 100644 --- a/packages/graphiql-plugin-doc-explorer/package.json +++ b/packages/graphiql-plugin-doc-explorer/package.json @@ -35,6 +35,8 @@ "types:check": "tsgo --noEmit", "dev": "vite build --watch --emptyOutDir=false", "build": "vite build", + "build-storybook": "storybook build", + "storybook": "storybook dev -p 6007", "test": "vitest run" }, "peerDependencies": { @@ -50,6 +52,9 @@ }, "devDependencies": { "@graphiql/react": "^0.37.4", + "@storybook/addon-a11y": "^10.3.6", + "@storybook/addon-vitest": "^10.3.6", + "@storybook/react-vite": "^10.3.6", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -58,6 +63,7 @@ "graphql": "^16.9.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "storybook": "^10.3.6", "vite": "^6.3.4", "vite-plugin-dts": "^4.5.3" } diff --git a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/doc-explorer.spec.tsx b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/doc-explorer.spec.tsx index fe10367daba..324385ccf55 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/doc-explorer.spec.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/doc-explorer.spec.tsx @@ -1,5 +1,5 @@ import { type Mock, describe, it, expect, vi, beforeEach } from 'vitest'; -import { useGraphiQL as $useGraphiQL } from '@graphiql/react'; +import { useGraphiQL as $useGraphiQL, Tooltip } from '@graphiql/react'; import { render } from '@testing-library/react'; import { GraphQLInt, GraphQLObjectType, GraphQLSchema } from 'graphql'; import { FC, useEffect } from 'react'; @@ -54,9 +54,11 @@ const withErrorSchemaContext = { const DocExplorerWithContext: FC = () => { return ( - - - + + + + + ); }; @@ -88,8 +90,8 @@ describe('DocExplorer', () => { const error = container.querySelectorAll('.graphiql-doc-explorer-error'); expect(error).toHaveLength(0); expect( - container.querySelector('.graphiql-markdown-description'), - ).toHaveTextContent('GraphQL Schema for testing'); + container.querySelector('.graphiql-doc-explorer-schema-overview'), + ).toBeInTheDocument(); }); it('renders correctly with schema error', () => { useGraphiQL.mockImplementation(cb => cb(withErrorSchemaContext)); @@ -124,20 +126,27 @@ describe('DocExplorer', () => { cb({ ...defaultSchemaContext, schema: initialSchema }), ); const { container, rerender } = render( - - - , + + + + + , ); // First proper render of doc explorer rerender( - - - , + + + + + , ); - const title = container.querySelector('.graphiql-doc-explorer-title')!; - expect(title.textContent).toEqual('field'); + // The current page is shown as the last breadcrumb segment + const breadcrumb = container.querySelector( + '.graphiql-doc-explorer-breadcrumb-current', + )!; + expect(breadcrumb.textContent).toEqual('field'); // Second render of doc explorer, this time with a new schema, with _same_ field name useGraphiQL.mockImplementation(cb => @@ -147,13 +156,17 @@ describe('DocExplorer', () => { }), ); rerender( - - - , + + + + + , ); - const title2 = container.querySelector('.graphiql-doc-explorer-title')!; + const breadcrumb2 = container.querySelector( + '.graphiql-doc-explorer-breadcrumb-current', + )!; // Because `Query.field` still exists in the new schema, we can still render it - expect(title2.textContent).toEqual('field'); + expect(breadcrumb2.textContent).toEqual('field'); }); it('trims nav stack when necessary', () => { const initialSchema = makeSchema(); @@ -179,20 +192,26 @@ describe('DocExplorer', () => { cb({ ...defaultSchemaContext, schema: initialSchema }), ); const { container, rerender } = render( - - - , + + + + + , ); // First proper render of doc explorer rerender( - - - , + + + + + , ); - const title = container.querySelector('.graphiql-doc-explorer-title')!; - expect(title.textContent).toEqual('field'); + const breadcrumb = container.querySelector( + '.graphiql-doc-explorer-breadcrumb-current', + )!; + expect(breadcrumb.textContent).toEqual('field'); // Second render of doc explorer, this time with a new schema, with a different field name useGraphiQL.mockImplementation(cb => @@ -202,12 +221,16 @@ describe('DocExplorer', () => { }), ); rerender( - - - , + + + + + , ); - const title2 = container.querySelector('.graphiql-doc-explorer-title')!; + const breadcrumb2 = container.querySelector( + '.graphiql-doc-explorer-breadcrumb-current', + )!; // Because `Query.field` doesn't exist anymore, the top-most item we can render is `Query` - expect(title2.textContent).toEqual('Query'); + expect(breadcrumb2.textContent).toEqual('Query'); }); }); diff --git a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/field-documentation.spec.tsx b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/field-documentation.spec.tsx index 65e0219e628..09c707671a9 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/__tests__/field-documentation.spec.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/__tests__/field-documentation.spec.tsx @@ -78,7 +78,7 @@ describe('FieldDocumentation', () => { />, ); expect( - container.querySelector('.graphiql-markdown-description'), + container.querySelector('.graphiql-doc-explorer-field-card-description'), ).not.toBeInTheDocument(); expect( container.querySelector('.graphiql-doc-explorer-type-name'), @@ -95,7 +95,7 @@ describe('FieldDocumentation', () => { />, ); expect( - container.querySelector('.graphiql-markdown-description'), + container.querySelector('.graphiql-doc-explorer-field-card-description'), ).not.toBeInTheDocument(); expect( container.querySelector('.graphiql-doc-explorer-type-name'), @@ -113,7 +113,7 @@ describe('FieldDocumentation', () => { container.querySelector('.graphiql-doc-explorer-type-name'), ).toHaveTextContent('String'); expect( - container.querySelector('.graphiql-markdown-description'), + container.querySelector('.graphiql-doc-explorer-field-card-description'), ).toHaveTextContent('Example String field with arguments'); }); @@ -127,14 +127,14 @@ describe('FieldDocumentation', () => { container.querySelector('.graphiql-doc-explorer-type-name'), ).toHaveTextContent('String'); expect( - container.querySelector('.graphiql-markdown-description'), + container.querySelector('.graphiql-doc-explorer-field-card-description'), ).toHaveTextContent('Example String field with arguments'); expect( container.querySelectorAll('.graphiql-doc-explorer-argument'), ).toHaveLength(1); expect( container.querySelector('.graphiql-doc-explorer-argument'), - ).toHaveTextContent('stringArg: String'); + ).toHaveTextContent('stringArg:String'); // by default, the deprecation docs should be hidden expect( container.querySelectorAll('.graphiql-markdown-deprecation'), diff --git a/packages/graphiql-plugin-doc-explorer/src/components/argument.css b/packages/graphiql-plugin-doc-explorer/src/components/argument.css index 56cc073dd0c..7c80a917428 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/argument.css +++ b/packages/graphiql-plugin-doc-explorer/src/components/argument.css @@ -5,14 +5,14 @@ } .graphiql-doc-explorer-argument-name { - color: hsl(var(--color-secondary)); + color: oklch(var(--accent-purple)); } .graphiql-doc-explorer-argument-deprecation { - background-color: hsla(var(--color-warning), var(--alpha-background-light)); - border: 1px solid hsl(var(--color-warning)); + background-color: oklch(var(--accent-orange) / 0.1); + border: 1px solid oklch(var(--accent-orange)); border-radius: var(--border-radius-4); - color: hsl(var(--color-warning)); + color: oklch(var(--accent-orange)); padding: var(--px-8); } diff --git a/packages/graphiql-plugin-doc-explorer/src/components/arguments-list.css b/packages/graphiql-plugin-doc-explorer/src/components/arguments-list.css new file mode 100644 index 00000000000..f444085807b --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/arguments-list.css @@ -0,0 +1,140 @@ +.graphiql-doc-explorer-arguments-list { + display: flex; + flex-direction: column; + padding: var(--px-4) 0; +} + +/* Section header — "ARGUMENTS · N" / "DIRECTIVES · N" */ +.graphiql-doc-explorer-arguments-list-header { + display: flex; + align-items: center; + gap: var(--px-6); + padding: var(--px-6) var(--px-16) var(--px-4); + background: transparent; + border: none; + cursor: pointer; + color: oklch(var(--fg-subtle)); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + width: 100%; + text-align: left; + + & svg { + width: 9px; + height: 9px; + flex-shrink: 0; + } + + &:hover { + color: oklch(var(--fg-muted)); + } + + &:focus-visible { + outline: 2px solid oklch(var(--accent-blue)); + outline-offset: -2px; + } +} + +.graphiql-doc-explorer-arguments-list-count { + color: oklch(var(--fg-dim)); + font-weight: 500; +} + +.graphiql-doc-explorer-arguments-list-body { + display: flex; + flex-direction: column; +} + +/* Individual argument row */ +.graphiql-doc-explorer-argument { + display: flex; + flex-direction: column; + gap: var(--px-4); + padding: 5px var(--px-16) 5px var(--px-24); +} + +.graphiql-doc-explorer-argument--deprecated { + opacity: 0.6; +} + +/* Signature line: name : type = default */ +.graphiql-doc-explorer-argument-sig { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: var(--px-4); + font-family: var(--font-family-mono); + font-size: 12px; +} + +.graphiql-doc-explorer-argument-name { + color: oklch(var(--accent-green-light)); +} + +.graphiql-doc-explorer-argument-colon { + color: oklch(var(--fg-disabled)); +} + +.graphiql-doc-explorer-argument-type { + color: oklch(var(--accent-orange)); + + & .graphiql-doc-explorer-type-name { + color: oklch(var(--accent-orange)); + + &:hover { + text-decoration: underline; + } + } +} + +.graphiql-doc-explorer-argument-default { + color: oklch(var(--fg-muted)); +} + +.graphiql-doc-explorer-argument-desc { + font-size: 11px; + color: oklch(var(--fg-subtle)); + line-height: 15px; +} + +.graphiql-doc-explorer-arguments-list-show-deprecated { + margin: var(--px-8) var(--px-16); +} + +/* Directives — same eyebrow layout as ArgumentsList */ +.graphiql-doc-explorer-directives-list { + display: flex; + flex-direction: column; + padding: var(--px-4) 0; +} + +.graphiql-doc-explorer-directives-list-header { + padding: var(--px-6) var(--px-16) var(--px-4); + color: oklch(var(--fg-subtle)); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.graphiql-doc-explorer-directives-list-count { + color: oklch(var(--fg-dim)); + font-weight: 500; +} + +.graphiql-doc-explorer-directives-list-body { + display: flex; + flex-direction: column; +} + +.graphiql-doc-explorer-directive-row { + padding: 5px var(--px-16) 5px var(--px-24); + font-family: var(--font-family-mono); + font-size: 12px; +} + +.graphiql-doc-explorer-directive-row .graphiql-doc-explorer-directive { + color: oklch(var(--accent-orange)); +} diff --git a/packages/graphiql-plugin-doc-explorer/src/components/arguments-list.tsx b/packages/graphiql-plugin-doc-explorer/src/components/arguments-list.tsx new file mode 100644 index 00000000000..ed36f970da6 --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/arguments-list.tsx @@ -0,0 +1,115 @@ +import { FC, useState } from 'react'; +import { astFromValue, print, type GraphQLArgument } from 'graphql'; +import { Button, ChevronDownIcon, ChevronUpIcon } from '@graphiql/react'; +import { DeprecationReason } from './deprecation-reason'; +import { TypeLink } from './type-link'; +import { renderType } from './utils'; +import './arguments-list.css'; + +type ArgumentsListProps = { + title: 'ARGUMENTS' | 'DEPRECATED ARGUMENTS'; + args: GraphQLArgument[]; +}; + +export const ArgumentsList: FC = ({ title, args }) => { + const [expanded, setExpanded] = useState(true); + + if (args.length === 0) { + return null; + } + + return ( +
+ + {expanded && ( +
+ {args.map(arg => ( + + ))} +
+ )} +
+ ); +}; + +type ShowDeprecatedArgumentsButtonProps = { + onClick: () => void; +}; + +export const ShowDeprecatedArgumentsButton: FC< + ShowDeprecatedArgumentsButtonProps +> = ({ onClick }) => ( + +); + +type ArgumentRowProps = { + arg: GraphQLArgument; +}; + +const ArgumentRow: FC = ({ arg }) => { + const defaultAst = + arg.defaultValue === undefined + ? null + : astFromValue(arg.defaultValue, arg.type); + const isDeprecated = Boolean(arg.deprecationReason); + + return ( +
+
+ {arg.name} + + + {renderType(arg.type, namedType => ( + + ))} + + {defaultAst && ( + + = {print(defaultAst)} + + )} +
+ {arg.description && ( +
+ {arg.description} +
+ )} + {arg.deprecationReason ? ( + + {arg.deprecationReason} + + ) : null} +
+ ); +}; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/breadcrumb.css b/packages/graphiql-plugin-doc-explorer/src/components/breadcrumb.css new file mode 100644 index 00000000000..2213a8ec1e1 --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/breadcrumb.css @@ -0,0 +1,50 @@ +.graphiql-doc-explorer-breadcrumb { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0; + padding: var(--px-6) var(--px-16) 0; + font-family: var(--font-family-mono); + font-size: 11.5px; + line-height: 1.4; +} + +.graphiql-doc-explorer-breadcrumb-segment { + display: flex; + align-items: center; + gap: var(--px-4); +} + +.graphiql-doc-explorer-breadcrumb-sep { + color: oklch(var(--fg-dim)); + margin: 0 var(--px-4); + font-family: var(--font-family); + font-size: 13px; +} + +/* Query root — accent blue */ +a.graphiql-doc-explorer-breadcrumb-root, +span.graphiql-doc-explorer-breadcrumb-root { + color: oklch(var(--accent-blue)); + text-decoration: none; +} + +a.graphiql-doc-explorer-breadcrumb-root:hover { + text-decoration: underline; +} + +/* Intermediate types — green */ +a.graphiql-doc-explorer-breadcrumb-intermediate, +span.graphiql-doc-explorer-breadcrumb-intermediate { + color: oklch(var(--accent-green-light)); + text-decoration: none; +} + +a.graphiql-doc-explorer-breadcrumb-intermediate:hover { + text-decoration: underline; +} + +/* Current (last) segment — strong foreground */ +span.graphiql-doc-explorer-breadcrumb-current { + color: oklch(var(--fg-strong)); +} diff --git a/packages/graphiql-plugin-doc-explorer/src/components/breadcrumb.tsx b/packages/graphiql-plugin-doc-explorer/src/components/breadcrumb.tsx new file mode 100644 index 00000000000..50d31a01853 --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/breadcrumb.tsx @@ -0,0 +1,62 @@ +import type { FC } from 'react'; +import type { DocExplorerNavStack } from '../context'; +import './breadcrumb.css'; + +type BreadcrumbProps = { + navStack: DocExplorerNavStack; + onNavigateTo: (index: number) => void; +}; + +export const Breadcrumb: FC = ({ navStack, onNavigateTo }) => { + if (navStack.length <= 1) { + return null; + } + + return ( + + ); +}; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/default-value.css b/packages/graphiql-plugin-doc-explorer/src/components/default-value.css index 59c2dcf2454..c6b18307640 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/default-value.css +++ b/packages/graphiql-plugin-doc-explorer/src/components/default-value.css @@ -1,3 +1,3 @@ .graphiql-doc-explorer-default-value { - color: hsl(var(--color-success)); + color: oklch(var(--accent-green)); } diff --git a/packages/graphiql-plugin-doc-explorer/src/components/deprecation-reason.css b/packages/graphiql-plugin-doc-explorer/src/components/deprecation-reason.css index 123929af72d..32fe94a6d39 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/deprecation-reason.css +++ b/packages/graphiql-plugin-doc-explorer/src/components/deprecation-reason.css @@ -1,8 +1,8 @@ .graphiql-doc-explorer-deprecation { - background-color: hsla(var(--color-warning), var(--alpha-background-light)); - border: 1px solid hsl(var(--color-warning)); + background-color: oklch(var(--accent-orange) / 0.1); + border: 1px solid oklch(var(--accent-orange)); border-radius: var(--px-4); - color: hsl(var(--color-warning)); + color: oklch(var(--accent-orange)); padding: var(--px-8); } diff --git a/packages/graphiql-plugin-doc-explorer/src/components/directive.css b/packages/graphiql-plugin-doc-explorer/src/components/directive.css index 17783cac86b..07c110f84f4 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/directive.css +++ b/packages/graphiql-plugin-doc-explorer/src/components/directive.css @@ -1,3 +1,3 @@ .graphiql-doc-explorer-directive { - color: hsl(var(--color-secondary)); + color: oklch(var(--accent-purple)); } diff --git a/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.css b/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.css index 88178fa122d..1bb60b6a48c 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.css +++ b/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.css @@ -1,102 +1,61 @@ -/* The header of the doc explorer */ -.graphiql-doc-explorer-header { - display: flex; - justify-content: space-between; - position: relative; - - &:focus-within { - & .graphiql-doc-explorer-title { - /* Hide the header when focussing the search input */ - visibility: hidden; - } - - & .graphiql-doc-explorer-back:not(:focus) { - /** - * Make the back link invisible when focussing the search input. Hiding - * it in any other way makes it impossible to focus the link by pressing - * Shift-Tab while the input is focussed. - */ - color: transparent; - } - } -} -.graphiql-doc-explorer-header-content { - display: flex; - flex-direction: column; - min-width: 0; +/* Eyebrow title in PanelHeader */ +.graphiql-panel-header-title { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: var(--font-size-eyebrow); } -/* The search input in the header of the doc explorer */ -.graphiql-doc-explorer-search { - position: absolute; - right: 0; - top: 0; - - &:focus-within { - left: 0; - } - - &:not(:focus-within) [role='combobox'] { - height: 24px; - width: 6.5ch; - } - - & [role='combobox']:focus { - width: 100%; - } +/* Main content area */ +.graphiql-doc-explorer-content { + overflow-y: auto; + flex: 1; } -/* The back-button in the doc explorer */ -a.graphiql-doc-explorer-back { - align-items: center; - color: hsla(var(--color-neutral), var(--alpha-secondary)); - display: flex; - text-decoration: none; - - &:hover { - text-decoration: underline; - } - - &:focus { - outline: hsla(var(--color-neutral), var(--alpha-secondary)) auto 1px; - - & + .graphiql-doc-explorer-title { - /* Don't hide the header when focussing the back link */ - visibility: unset; - } - } +/* Schema overview and field-documentation padding */ +.graphiql-doc-explorer-content > * { + color: oklch(var(--fg-muted)); + padding: 0 var(--px-16) var(--px-16); + margin-top: var(--px-16); +} - & > svg { - height: var(--px-8); - margin-right: var(--px-8); - width: var(--px-8); - } +/* TypeCard and FieldCard keep their own horizontal padding */ +.graphiql-doc-explorer-content .graphiql-doc-explorer-type-card, +.graphiql-doc-explorer-content .graphiql-doc-explorer-field-card { + padding: var(--px-10) var(--px-16); + margin-top: 0; } -/* The title of the currently active page in the doc explorer */ -.graphiql-doc-explorer-title { - font-weight: var(--font-weight-medium); - font-size: var(--font-size-h2); - overflow-x: hidden; - text-overflow: ellipsis; - white-space: nowrap; - &:not(:first-child) { - font-size: var(--font-size-h3); - margin-top: var(--px-8); - } +/* FieldsList and ArgumentsList rows span full width, so zero out container padding */ +.graphiql-doc-explorer-content .graphiql-doc-explorer-fields-list, +.graphiql-doc-explorer-content .graphiql-doc-explorer-arguments-list { + padding-left: 0; + padding-right: 0; + padding-top: 0; + margin-top: 0; } -/* The contents of the currently active page in the doc explorer */ -.graphiql-doc-explorer-content > * { - color: hsla(var(--color-neutral), var(--alpha-secondary)); - margin-top: var(--px-20); +/* Wrap for enum/union/interface extra sections */ +.graphiql-doc-explorer-type-extra { + padding: 0 var(--px-16) var(--px-16); + margin-top: var(--px-16); + color: oklch(var(--fg-muted)); } /* Error message */ .graphiql-doc-explorer-error { - background-color: hsla(var(--color-error), var(--alpha-background-heavy)); - border: 1px solid hsl(var(--color-error)); - border-radius: var(--border-radius-8); - color: hsl(var(--color-error)); + background-color: oklch(var(--accent-red) / 0.15); + border: 1px solid oklch(var(--accent-red)); + border-radius: var(--radius-sm); + color: oklch(var(--accent-red)); padding: var(--px-8) var(--px-12); + margin: var(--px-16); +} + +/* Old back-button and title selectors kept for Cypress compatibility */ +a.graphiql-doc-explorer-back { + display: none; +} + +.graphiql-doc-explorer-title { + display: none; } diff --git a/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.tsx b/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.tsx index df6d249b595..3a6ce7cec84 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/doc-explorer.tsx @@ -1,10 +1,21 @@ -import { isType } from 'graphql'; +import { + GraphQLNamedType, + isEnumType, + isInputObjectType, + isInterfaceType, + isObjectType, + isType, + isUnionType, +} from 'graphql'; import type { FC, ReactNode } from 'react'; -import { ChevronLeftIcon, Spinner, useGraphiQL, pick } from '@graphiql/react'; +import { PanelHeader, Spinner, useGraphiQL, pick } from '@graphiql/react'; import { useDocExplorer, useDocExplorerActions } from '../context'; +import { Breadcrumb } from './breadcrumb'; import { FieldDocumentation } from './field-documentation'; +import { FieldsList } from './fields-list'; import { SchemaDocumentation } from './schema-documentation'; -import { Search } from './search'; +import { SearchRow } from './search-row'; +import { TypeCard } from './type-card'; import { TypeDocumentation } from './type-documentation'; import './doc-explorer.css'; @@ -16,6 +27,13 @@ export const DocExplorer: FC = () => { const { pop } = useDocExplorerActions(); const navItem = explorerNavStack.at(-1)!; + const navigateToIndex = (index: number) => { + const stepsBack = explorerNavStack.length - 1 - index; + for (let i = 0; i < stepsBack; i++) { + pop(); + } + }; + let content: ReactNode = null; if (fetchError) { content = ( @@ -28,11 +46,8 @@ export const DocExplorer: FC = () => { ); } else if (isIntrospecting) { - // Schema is undefined when it is being loaded via introspection. content = ; } else if (!schema) { - // Schema is null when it explicitly does not exist, typically due to - // an error during introspection. content = (
No GraphQL schema available @@ -41,42 +56,48 @@ export const DocExplorer: FC = () => { } else if (explorerNavStack.length === 1) { content = ; } else if (isType(navItem.def)) { - content = ; + content = ; } else if (navItem.def) { content = ; } - let prevName; - if (explorerNavStack.length > 1) { - prevName = explorerNavStack.at(-2)!.name; - } + const isTypeOrFieldView = explorerNavStack.length > 1; return (
- + + {isTypeOrFieldView && ( + + )} +
{content}
); }; + +const TypeView: FC<{ type: GraphQLNamedType }> = ({ type }) => { + const hasNewFieldsList = + isObjectType(type) || isInterfaceType(type) || isInputObjectType(type); + // Enum/union/scalar still need the TypeDocumentation for enum values and + // possible-type sections; interface needs it for Implementations. + const needsTypeDocs = + isEnumType(type) || isUnionType(type) || isInterfaceType(type); + + return ( + <> + + {hasNewFieldsList && } + {needsTypeDocs && ( +
+ +
+ )} + + ); +}; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/field-card.css b/packages/graphiql-plugin-doc-explorer/src/components/field-card.css new file mode 100644 index 00000000000..b17d6260076 --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/field-card.css @@ -0,0 +1,45 @@ +.graphiql-doc-explorer-field-card { + border-top: 1px solid oklch(var(--border-default)); +} + +.graphiql-doc-explorer-field-card-header { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: var(--px-6); + margin-bottom: var(--px-4); +} + +.graphiql-doc-explorer-field-card-name { + font-family: var(--font-family-mono); + font-size: 14px; + font-weight: 600; + color: oklch(var(--accent-green-light)); +} + +.graphiql-doc-explorer-field-card-colon { + font-family: var(--font-family-mono); + font-size: 14px; + color: oklch(var(--fg-muted)); +} + +.graphiql-doc-explorer-field-card-type { + font-family: var(--font-family-mono); + font-size: 14px; + color: oklch(var(--accent-orange)); + + & .graphiql-doc-explorer-type-name { + color: oklch(var(--accent-orange)); + + &:hover { + text-decoration: underline; + } + } +} + +.graphiql-doc-explorer-field-card-description { + margin: 0 0 var(--px-6); + font-size: 12px; + color: oklch(var(--fg-muted)); + line-height: 17px; +} diff --git a/packages/graphiql-plugin-doc-explorer/src/components/field-card.tsx b/packages/graphiql-plugin-doc-explorer/src/components/field-card.tsx new file mode 100644 index 00000000000..301e7a4f4c8 --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/field-card.tsx @@ -0,0 +1,44 @@ +import type { FC } from 'react'; +import type { DocExplorerFieldDef } from '../context'; +import { DeprecationReason } from './deprecation-reason'; +import { TypeLink } from './type-link'; +import { renderType } from './utils'; +import './field-card.css'; + +type FieldCardProps = { + field: DocExplorerFieldDef; +}; + +export const FieldCard: FC = ({ field }) => { + return ( +
+
+ FIELD + + {field.name} + + + + {renderType(field.type, namedType => ( + + ))} + +
+ {field.description && ( +

+ {field.description} +

+ )} + {'deprecationReason' in field && field.deprecationReason ? ( + + {field.deprecationReason} + + ) : null} +
+ ); +}; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/field-documentation.tsx b/packages/graphiql-plugin-doc-explorer/src/components/field-documentation.tsx index c4c6ec4f253..e45397503de 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/field-documentation.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/field-documentation.tsx @@ -1,12 +1,9 @@ -import type { GraphQLArgument } from 'graphql'; +import type { ConstDirectiveNode, GraphQLArgument } from 'graphql'; import { FC, useState } from 'react'; -import { Button, MarkdownContent } from '@graphiql/react'; import type { DocExplorerFieldDef } from '../context'; -import { Argument } from './argument'; -import { DeprecationReason } from './deprecation-reason'; +import { ArgumentsList, ShowDeprecatedArgumentsButton } from './arguments-list'; import { Directive } from './directive'; -import { ExplorerSection } from './section'; -import { TypeLink } from './type-link'; +import { FieldCard } from './field-card'; type FieldDocumentationProps = { /** @@ -18,17 +15,7 @@ type FieldDocumentationProps = { export const FieldDocumentation: FC = ({ field }) => { return ( <> - {field.description ? ( - - {field.description} - - ) : null} - - {field.deprecationReason} - - - - + @@ -37,9 +24,6 @@ export const FieldDocumentation: FC = ({ field }) => { const Arguments: FC<{ field: DocExplorerFieldDef }> = ({ field }) => { const [showDeprecated, setShowDeprecated] = useState(false); - const handleShowDeprecated = () => { - setShowDeprecated(true); - }; if (!('args' in field)) { return null; @@ -57,42 +41,44 @@ const Arguments: FC<{ field: DocExplorerFieldDef }> = ({ field }) => { return ( <> - {args.length > 0 ? ( - - {args.map(arg => ( - - ))} - - ) : null} - {deprecatedArgs.length > 0 ? ( - showDeprecated || args.length === 0 ? ( - - {deprecatedArgs.map(arg => ( - - ))} - + + {deprecatedArgs.length > 0 && + (showDeprecated || args.length === 0 ? ( + ) : ( - - ) - ) : null} + setShowDeprecated(true)} + /> + ))} ); }; const Directives: FC<{ field: DocExplorerFieldDef }> = ({ field }) => { - const directives = field.astNode?.directives; + const directives = field.astNode?.directives as + | readonly ConstDirectiveNode[] + | undefined; if (!directives?.length) { return null; } return ( - - {directives.map(directive => ( -
- -
- ))} -
+
+
+ DIRECTIVES{' '} + + · {directives.length} + +
+
+ {directives.map(directive => ( +
+ +
+ ))} +
+
); }; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/field-link.css b/packages/graphiql-plugin-doc-explorer/src/components/field-link.css index ff377b0902b..91a92d12714 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/field-link.css +++ b/packages/graphiql-plugin-doc-explorer/src/components/field-link.css @@ -1,5 +1,5 @@ a.graphiql-doc-explorer-field-name { - color: hsl(var(--color-info)); + color: oklch(var(--accent-green-light)); text-decoration: none; &:hover { @@ -7,6 +7,6 @@ a.graphiql-doc-explorer-field-name { } &:focus { - outline: hsl(var(--color-info)) auto 1px; + outline: oklch(var(--accent-green-light)) auto 1px; } } diff --git a/packages/graphiql-plugin-doc-explorer/src/components/fields-list.css b/packages/graphiql-plugin-doc-explorer/src/components/fields-list.css new file mode 100644 index 00000000000..c2353dbdcc3 --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/fields-list.css @@ -0,0 +1,130 @@ +.graphiql-doc-explorer-fields-list { + flex: 1; + overflow: auto; + display: flex; + flex-direction: column; + padding: var(--px-4) 0; +} + +/* Section header — "FIELDS · N" */ +.graphiql-doc-explorer-fields-list-header { + display: flex; + align-items: center; + gap: var(--px-6); + padding: var(--px-6) var(--px-16) var(--px-4); + background: transparent; + border: none; + cursor: pointer; + color: oklch(var(--fg-subtle)); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + width: 100%; + text-align: left; + + & svg { + width: 9px; + height: 9px; + flex-shrink: 0; + } + + &:hover { + color: oklch(var(--fg-muted)); + } + + &:focus-visible { + outline: 2px solid oklch(var(--accent-blue)); + outline-offset: -2px; + } +} + +.graphiql-doc-explorer-fields-list-count { + color: oklch(var(--fg-dim)); + font-weight: 500; +} + +.graphiql-doc-explorer-fields-list-body { + display: flex; + flex-direction: column; +} + +/* Individual field row */ +.graphiql-doc-explorer-field-row { + display: flex; + flex-direction: column; + gap: var(--px-4); + padding: 5px var(--px-12) 5px var(--px-24); + background: transparent; + border: none; + border-left: 2px solid transparent; + cursor: pointer; + text-align: left; + width: 100%; + + &:hover { + background: oklch(var(--fg-default) / 0.04); + } + + &:focus-visible { + outline: 2px solid oklch(var(--accent-blue)); + outline-offset: -2px; + } +} + +/* Active row — blue accent */ +.graphiql-doc-explorer-field-row--active { + background: oklch(var(--accent-blue) / 0.08); + border-left-color: oklch(var(--accent-blue)); + margin-left: -2px; + + &:hover { + background: oklch(var(--accent-blue) / 0.12); + } +} + +.graphiql-doc-explorer-field-row--deprecated { + opacity: 0.6; +} + +/* Signature line: name : type */ +.graphiql-doc-explorer-field-row-sig { + display: flex; + align-items: baseline; + gap: var(--px-6); + font-family: var(--font-family-mono); + font-size: 12px; +} + +.graphiql-doc-explorer-field-row-name { + color: oklch(var(--accent-green-light)); +} + +.graphiql-doc-explorer-field-row-colon { + color: oklch(var(--fg-disabled)); +} + +/* Return type color — inherits from TypeLink */ +.graphiql-doc-explorer-field-row-type { + color: oklch(var(--accent-orange)); + + /* TypeLink anchors inside a field row get orange color */ + & .graphiql-doc-explorer-type-name { + color: oklch(var(--accent-orange)); + + &:hover { + text-decoration: underline; + } + } +} + +/* Optional description */ +.graphiql-doc-explorer-field-row-desc { + font-size: 11px; + color: oklch(var(--fg-subtle)); + line-height: 15px; +} + +.graphiql-doc-explorer-fields-list-show-deprecated { + margin: var(--px-8) var(--px-16); +} diff --git a/packages/graphiql-plugin-doc-explorer/src/components/fields-list.tsx b/packages/graphiql-plugin-doc-explorer/src/components/fields-list.tsx new file mode 100644 index 00000000000..8dcc5caea2b --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/fields-list.tsx @@ -0,0 +1,137 @@ +import { FC, useState } from 'react'; +import { + GraphQLNamedType, + isObjectType, + isInterfaceType, + isInputObjectType, +} from 'graphql'; +import { Button, ChevronDownIcon, ChevronUpIcon } from '@graphiql/react'; +import type { DocExplorerFieldDef } from '../context'; +import { useDocExplorerActions } from '../context'; +import { TypeLink } from './type-link'; +import { renderType } from './utils'; +import './fields-list.css'; + +type FieldsListProps = { + type: GraphQLNamedType; + activeFieldName?: string; +}; + +export const FieldsList: FC = ({ type, activeFieldName }) => { + const [expanded, setExpanded] = useState(true); + const [showDeprecated, setShowDeprecated] = useState(false); + + if ( + !isObjectType(type) && + !isInterfaceType(type) && + !isInputObjectType(type) + ) { + return null; + } + + const fieldMap = type.getFields(); + const fields: DocExplorerFieldDef[] = []; + const deprecatedFields: DocExplorerFieldDef[] = []; + + for (const field of Object.values(fieldMap)) { + if (field.deprecationReason) { + deprecatedFields.push(field); + } else { + fields.push(field); + } + } + + const totalCount = + fields.length + (showDeprecated ? deprecatedFields.length : 0); + + return ( +
+ + {expanded && ( +
+ {fields.map(field => ( + + ))} + {deprecatedFields.length > 0 && + (showDeprecated || fields.length === 0 ? ( + deprecatedFields.map(field => ( + + )) + ) : ( + + ))} +
+ )} +
+ ); +}; + +type FieldRowProps = { + field: DocExplorerFieldDef; + isActive: boolean; + deprecated?: boolean; +}; + +const FieldRow: FC = ({ field, isActive, deprecated }) => { + const { push } = useDocExplorerActions(); + + return ( + + ); +}; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/index.ts b/packages/graphiql-plugin-doc-explorer/src/components/index.ts index 726eefb7198..4686c39120e 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/index.ts +++ b/packages/graphiql-plugin-doc-explorer/src/components/index.ts @@ -1,12 +1,18 @@ export { Argument } from './argument'; +export { ArgumentsList } from './arguments-list'; +export { Breadcrumb } from './breadcrumb'; export { DefaultValue } from './default-value'; export { DeprecationReason } from './deprecation-reason'; export { Directive } from './directive'; export { DocExplorer } from './doc-explorer'; +export { FieldCard } from './field-card'; export { FieldDocumentation } from './field-documentation'; export { FieldLink } from './field-link'; +export { FieldsList } from './fields-list'; export { SchemaDocumentation } from './schema-documentation'; export { Search } from './search'; +export { SearchRow } from './search-row'; export { ExplorerSection } from './section'; +export { TypeCard } from './type-card'; export { TypeDocumentation } from './type-documentation'; export { TypeLink } from './type-link'; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.css b/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.css index c4e690b4be0..110ee3b2dce 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.css +++ b/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.css @@ -1,3 +1,89 @@ -.graphiql-doc-explorer-root-type { - color: hsl(var(--color-info)); +.graphiql-doc-explorer-schema-overview { + display: flex; + flex-direction: column; + padding: var(--px-8) 0; +} + +/* Eyebrow section headers — matches FieldsList header style */ +.graphiql-doc-explorer-schema-section-header { + padding: var(--px-6) var(--px-16) var(--px-4); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: oklch(var(--fg-subtle)); +} + +.graphiql-doc-explorer-schema-section-header--types { + margin-top: var(--px-12); +} + +.graphiql-doc-explorer-schema-type-count { + color: oklch(var(--fg-dim)); + font-weight: 500; +} + +/* Root type rows */ +.graphiql-doc-explorer-schema-root-row { + display: flex; + align-items: center; + gap: var(--px-6); + padding: 5px var(--px-16); + background: transparent; + border: none; + cursor: pointer; + text-align: left; + width: 100%; + font-family: var(--font-family-mono); + font-size: 12px; + + &:hover { + background: oklch(var(--fg-default) / 0.04); + } + + &:focus-visible { + outline: 2px solid oklch(var(--accent-blue)); + outline-offset: -2px; + } +} + +.graphiql-doc-explorer-schema-root-label { + color: oklch(var(--fg-default)); +} + +.graphiql-doc-explorer-schema-root-colon { + color: oklch(var(--fg-muted)); +} + +/* Type name in root rows — orange like TypeLink */ +.graphiql-doc-explorer-schema-root-row .graphiql-doc-explorer-type-name { + color: oklch(var(--accent-orange)); +} + +/* All-types rows */ +.graphiql-doc-explorer-schema-type-row { + display: flex; + align-items: center; + gap: var(--px-8); + padding: 4px var(--px-16); + background: transparent; + border: none; + cursor: pointer; + text-align: left; + width: 100%; + + &:hover { + background: oklch(var(--fg-default) / 0.04); + } + + &:focus-visible { + outline: 2px solid oklch(var(--accent-blue)); + outline-offset: -2px; + } +} + +.graphiql-doc-explorer-schema-type-name { + font-family: var(--font-family-mono); + font-size: 12px; + color: oklch(var(--fg-default)); } diff --git a/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.tsx b/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.tsx index 280533644c7..af8c665c542 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/schema-documentation.tsx @@ -1,8 +1,15 @@ import type { FC } from 'react'; -import type { GraphQLSchema } from 'graphql'; -import { MarkdownContent } from '@graphiql/react'; -import { ExplorerSection } from './section'; -import { TypeLink } from './type-link'; +import type { GraphQLNamedType, GraphQLSchema } from 'graphql'; +import { + isObjectType, + isInterfaceType, + isInputObjectType, + isEnumType, + isScalarType, + isUnionType, +} from 'graphql'; +import { MethodPill } from '@graphiql/react'; +import { useDocExplorerActions } from '../context'; import './schema-documentation.css'; type SchemaDocumentationProps = { @@ -12,68 +19,129 @@ type SchemaDocumentationProps = { schema: GraphQLSchema; }; +function getTypeKindLabel(type: GraphQLNamedType): string { + if (isObjectType(type)) { + return 'TYPE'; + } + if (isInterfaceType(type)) { + return 'INTERFACE'; + } + if (isInputObjectType(type)) { + return 'INPUT'; + } + if (isEnumType(type)) { + return 'ENUM'; + } + if (isScalarType(type)) { + return 'SCALAR'; + } + if (isUnionType(type)) { + return 'UNION'; + } + return 'TYPE'; +} + export const SchemaDocumentation: FC = ({ schema, }) => { + const { push } = useDocExplorerActions(); const queryType = schema.getQueryType(); const mutationType = schema.getMutationType(); const subscriptionType = schema.getSubscriptionType(); - const typeMap = schema.getTypeMap(); - const ignoreTypesInAllSchema = [ - queryType?.name, - mutationType?.name, - subscriptionType?.name, - ]; + const rootTypeNames = new Set( + [queryType?.name, mutationType?.name, subscriptionType?.name].filter( + Boolean, + ), + ); + + const allTypes = Object.values(schema.getTypeMap()).filter( + type => !type.name.startsWith('__') && !rootTypeNames.has(type.name), + ); return ( - <> - - {schema.description || - 'A GraphQL schema provides a root type for each kind of operation.'} - - - {queryType ? ( -
- query - {': '} - -
- ) : null} +
+ {/* Root types section */} +
+ ROOT TYPES +
+
+ {queryType && ( + + )} {mutationType && ( -
- mutation - {': '} - -
+ )} {subscriptionType && ( -
- - subscription +
+ )} - - -
- {Object.values(typeMap).map(type => { - if ( - ignoreTypesInAllSchema.includes(type.name) || - type.name.startsWith('__') - ) { - return null; - } +
- return ( -
- -
- ); - })} -
- - + {/* All schema types section */} +
+ ALL SCHEMA TYPES + + {' '} + · {allTypes.length} + +
+
+ {allTypes.map(type => ( + + ))} +
+
); }; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/search-row.css b/packages/graphiql-plugin-doc-explorer/src/components/search-row.css new file mode 100644 index 00000000000..75166f40776 --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/search-row.css @@ -0,0 +1,87 @@ +.graphiql-doc-explorer-search-row-wrapper { + padding: var(--px-8) var(--px-16); + position: relative; +} + +.graphiql-doc-explorer-search-row { + position: relative; +} + +.graphiql-doc-explorer-search-row-input { + display: flex; + align-items: center; + gap: var(--px-6); + background: oklch(var(--bg-subtle)); + border: 1px solid oklch(var(--border-muted)); + border-radius: var(--radius-sm); + padding: var(--px-4) var(--px-8); + cursor: text; + + & svg { + width: 11px; + height: 11px; + color: oklch(var(--fg-disabled)); + flex-shrink: 0; + } + + & [role='combobox'] { + flex: 1; + min-width: 0; + background: transparent; + border: none; + font-family: var(--font-family-mono); + font-size: 11.5px; + color: oklch(var(--fg-default)); + + &::placeholder { + color: oklch(var(--fg-disabled)); + } + + &:focus { + outline: none; + } + } +} + +.graphiql-doc-explorer-search-row-listbox { + position: absolute; + top: calc(100% + var(--px-4)); + left: 0; + right: 0; + z-index: 5; + background: oklch(var(--bg-canvas)); + border: 1px solid oklch(var(--border-default)); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-popover); + max-height: 400px; + overflow-y: auto; + padding: var(--px-4); + font-size: var(--font-size-body); + margin: 0; + + & [role='option'] { + border-radius: var(--radius-sm); + color: oklch(var(--fg-muted)); + overflow-x: hidden; + padding: var(--px-8) var(--px-12); + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + + &[data-headlessui-state='active'] { + background-color: oklch(var(--fg-default) / 0.08); + } + + &:hover { + background-color: oklch(var(--fg-default) / 0.12); + } + + &[data-headlessui-state='active']:hover { + background-color: oklch(var(--fg-default) / 0.16); + } + + & + [role='option'] { + margin-top: var(--px-4); + } + } +} diff --git a/packages/graphiql-plugin-doc-explorer/src/components/search-row.tsx b/packages/graphiql-plugin-doc-explorer/src/components/search-row.tsx new file mode 100644 index 00000000000..04256a68076 --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/search-row.tsx @@ -0,0 +1,192 @@ +import { FC, useEffect, useRef, useState } from 'react'; +import { + GraphQLArgument, + GraphQLField, + GraphQLInputField, + GraphQLNamedType, + isInputObjectType, + isInterfaceType, + isObjectType, +} from 'graphql'; +import { + Combobox, + ComboboxInput, + ComboboxOptions, + ComboboxOption, +} from '@headlessui/react'; +import { + formatShortcutForOS, + MagnifyingGlassIcon, + KeycapHint, + debounce, + KEY_MAP, +} from '@graphiql/react'; +import { useDocExplorer, useDocExplorerActions } from '../context'; +import { useSearchResults } from './search'; +import { renderType } from './utils'; +import './search-row.css'; + +type TypeMatch = { type: GraphQLNamedType }; +type FieldMatch = { + type: GraphQLNamedType; + field: GraphQLField | GraphQLInputField; + argument?: GraphQLArgument; +}; + +export const SearchRow: FC = () => { + const explorerNavStack = useDocExplorer(); + const { push } = useDocExplorerActions(); + + const inputRef = useRef(null!); + const getSearchResults = useSearchResults(); + const [searchValue, setSearchValue] = useState(''); + const [results, setResults] = useState(() => getSearchResults(searchValue)); + const debouncedGetSearchResults = debounce(200, (search: string) => { + setResults(getSearchResults(search)); + }); + const [ref] = useState(inputRef); + const isFocused = ref.current === document.activeElement; + + useEffect(() => { + debouncedGetSearchResults(searchValue); + }, [debouncedGetSearchResults, searchValue]); + + const navItem = explorerNavStack.at(-1)!; + + const onSelect = (def: TypeMatch | FieldMatch | null) => { + if (!def) { + return; + } + push( + 'field' in def + ? { name: def.field.name, def: def.field } + : { name: def.type.name, def: def.type }, + ); + }; + + const shouldShow = + explorerNavStack.length === 1 || + isObjectType(navItem.def) || + isInterfaceType(navItem.def) || + isInputObjectType(navItem.def); + + if (!shouldShow) { + return null; + } + + const shortcutKeys = formatShortcutForOS(KEY_MAP.searchInDocs.key).split('-'); + + return ( +
+ +
{ + inputRef.current.focus(); + }} + > + + setSearchValue(event.target.value)} + placeholder="Search schema" + ref={inputRef} + value={searchValue} + data-cy="doc-explorer-input" + /> + {!isFocused && !searchValue && ( + + )} +
+ {isFocused && ( + + {results.within.length + + results.types.length + + results.fields.length === + 0 ? ( +
+ No results found +
+ ) : ( + results.within.map((result, i) => ( + + + + )) + )} + {results.within.length > 0 && + results.types.length + results.fields.length > 0 ? ( +
+ Other results +
+ ) : null} + {results.types.map((result, i) => ( + + + + ))} + {results.fields.map((result, i) => ( + + . + + + ))} +
+ )} +
+
+ ); +}; + +const SearchType: FC<{ type: GraphQLNamedType }> = ({ type }) => ( + {type.name} +); + +type SearchFieldProps = { + field: GraphQLField | GraphQLInputField; + argument?: GraphQLArgument; +}; + +const SearchField: FC = ({ field, argument }) => { + return ( + <> + {field.name} + {argument ? ( + <> + ( + + {argument.name} + + :{' '} + {renderType(argument.type, namedType => ( + + ))} + ) + + ) : null} + + ); +}; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/search.css b/packages/graphiql-plugin-doc-explorer/src/components/search.css index 8d1a4d02b1e..7c7ed30be89 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/search.css +++ b/packages/graphiql-plugin-doc-explorer/src/components/search.css @@ -1,59 +1,67 @@ .graphiql-doc-explorer-search { - color: hsla(var(--color-neutral), var(--alpha-secondary)); + color: oklch(var(--fg-muted)); &:not([data-state='idle']) { - border: var(--popover-border); - border-radius: var(--border-radius-4); - box-shadow: var(--popover-box-shadow); - color: hsl(var(--color-neutral)); + color: oklch(var(--fg-default)); & .graphiql-doc-explorer-search-input { - background: hsl(var(--color-base)); + background: oklch(var(--bg-canvas)); + border: 1px solid oklch(var(--border-default)); + box-shadow: 0 4px 12px oklch(0% 0 0 / 0.3); } } } .graphiql-doc-explorer-search-input { align-items: center; - background-color: hsla(var(--color-neutral), var(--alpha-background-light)); + background-color: oklch(var(--fg-default) / 0.08); border-radius: var(--border-radius-4); display: flex; - padding: var(--px-8) var(--px-12); + flex: 1; + gap: var(--px-8); + padding: var(--px-4) var(--px-8); } .graphiql-doc-explorer-search [role='combobox'] { border: none; background-color: transparent; - margin-left: var(--px-4); - width: 100%; + flex: 1; + min-width: 0; &:focus { outline: none; } } +.graphiql-doc-explorer-search:focus-within .graphiql-keycap-hint { + display: none; +} + .graphiql-doc-explorer-search [role='listbox'] { - background-color: hsl(var(--color-base)); - border: none; - border-bottom-left-radius: var(--border-radius-4); - border-bottom-right-radius: var(--border-radius-4); - border-top: 1px solid - hsla(var(--color-neutral), var(--alpha-background-heavy)); + background-color: oklch(var(--bg-canvas)); + border: 1px solid oklch(var(--border-default)); + border-radius: var(--border-radius-4); + box-shadow: 0 4px 12px oklch(0% 0 0 / 0.3); max-height: 400px; overflow-y: auto; margin: 0; font-size: var(--font-size-body); padding: var(--px-4); /** - * This makes sure that the logic for auto-scrolling the search results when - * using keyboard navigation works properly (we use `offsetTop` there). + * Float below the input as a separate popup so the dropdown does not + * appear glued to the panel header. `offsetTop` from the listbox is used + * for keyboard auto-scroll, which still works under absolute positioning. */ - position: relative; + position: absolute; + top: calc(100% + var(--px-4)); + left: 0; + right: 0; + z-index: 5; } .graphiql-doc-explorer-search [role='option'] { border-radius: var(--border-radius-4); - color: hsla(var(--color-neutral), var(--alpha-secondary)); + color: oklch(var(--fg-muted)); overflow-x: hidden; padding: var(--px-8) var(--px-12); text-overflow: ellipsis; @@ -61,18 +69,15 @@ cursor: pointer; &[data-headlessui-state='active'] { - background-color: hsla(var(--color-neutral), var(--alpha-background-light)); + background-color: oklch(var(--fg-default) / 0.08); } &:hover { - background-color: hsla( - var(--color-neutral), - var(--alpha-background-medium) - ); + background-color: oklch(var(--fg-default) / 0.12); } &[data-headlessui-state='active']:hover { - background-color: hsla(var(--color-neutral), var(--alpha-background-heavy)); + background-color: oklch(var(--fg-default) / 0.16); } & + & { @@ -81,19 +86,19 @@ } .graphiql-doc-explorer-search-type { - color: hsl(var(--color-info)); + color: oklch(var(--accent-orange)); } .graphiql-doc-explorer-search-field { - color: hsl(var(--color-warning)); + color: oklch(var(--accent-green-light)); } .graphiql-doc-explorer-search-argument { - color: hsl(var(--color-secondary)); + color: oklch(var(--accent-purple)); } .graphiql-doc-explorer-search-divider { - color: hsla(var(--color-neutral), var(--alpha-secondary)); + color: oklch(var(--fg-muted)); font-size: var(--font-size-hint); font-weight: var(--font-weight-medium); margin-top: var(--px-8); @@ -101,6 +106,6 @@ } .graphiql-doc-explorer-search-empty { - color: hsla(var(--color-neutral), var(--alpha-secondary)); + color: oklch(var(--fg-muted)); padding: var(--px-8) var(--px-12); } diff --git a/packages/graphiql-plugin-doc-explorer/src/components/search.tsx b/packages/graphiql-plugin-doc-explorer/src/components/search.tsx index 8e52a424618..f9e1fd806ab 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/search.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/search.tsx @@ -17,6 +17,7 @@ import { import { formatShortcutForOS, useGraphiQL, + KeycapHint, MagnifyingGlassIcon, debounce, KEY_MAP, @@ -58,6 +59,8 @@ export const Search: FC = () => { : { name: def.type.name, def: def.type }, ); }; + const shortcutKeys = formatShortcutForOS(KEY_MAP.searchInDocs.key).split('-'); + const shouldSearchBoxAppear = explorerNavStack.length === 1 || isObjectType(navItem.def) || @@ -85,13 +88,12 @@ export const Search: FC = () => { setSearchValue(event.target.value)} - placeholder={formatShortcutForOS( - formatShortcutForOS(KEY_MAP.searchInDocs.key).replaceAll('-', ' '), - )} + placeholder="Search Docs" ref={inputRef} value={searchValue} data-cy="doc-explorer-input" /> +
{isFocused && ( diff --git a/packages/graphiql-plugin-doc-explorer/src/components/type-card.css b/packages/graphiql-plugin-doc-explorer/src/components/type-card.css new file mode 100644 index 00000000000..73e8dd98dcc --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/type-card.css @@ -0,0 +1,76 @@ +.graphiql-doc-explorer-type-card { + border-top: 1px solid oklch(var(--border-default)); +} + +.graphiql-doc-explorer-type-card-header { + display: flex; + align-items: center; + gap: var(--px-6); + margin-bottom: var(--px-4); +} + +/* [TYPE] badge */ +.graphiql-doc-explorer-type-badge { + padding: 1px 6px; + background: oklch(var(--accent-blue) / 0.12); + color: oklch(var(--accent-blue)); + font-family: var(--font-family-mono); + font-size: 10px; + font-weight: 600; + border-radius: var(--radius-sm); + text-transform: uppercase; + letter-spacing: 0.04em; + flex-shrink: 0; +} + +.graphiql-doc-explorer-type-card-name { + font-family: var(--font-family-mono); + font-size: 14px; + font-weight: 600; + color: oklch(var(--fg-strong)); + overflow-x: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.graphiql-doc-explorer-type-card-description { + margin: 0 0 var(--px-6); + font-size: 12px; + color: oklch(var(--fg-muted)); + line-height: 17px; +} + +/* implements row */ +.graphiql-doc-explorer-type-card-implements { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--px-6); + margin-top: var(--px-6); + font-size: 10.5px; + color: oklch(var(--fg-subtle)); +} + +.graphiql-doc-explorer-type-card-implements-keyword { + font-family: var(--font-family-mono); +} + +.graphiql-doc-explorer-type-card-implements-item { + display: flex; + align-items: center; + gap: var(--px-6); +} + +.graphiql-doc-explorer-type-card-implements-dot { + color: oklch(var(--fg-dim)); +} + +a.graphiql-doc-explorer-type-card-implements-link { + font-family: var(--font-family-mono); + color: oklch(var(--accent-orange)); + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} diff --git a/packages/graphiql-plugin-doc-explorer/src/components/type-card.tsx b/packages/graphiql-plugin-doc-explorer/src/components/type-card.tsx new file mode 100644 index 00000000000..52ed749ef6a --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/components/type-card.tsx @@ -0,0 +1,92 @@ +import type { FC } from 'react'; +import { + GraphQLNamedType, + isObjectType, + isInterfaceType, + isInputObjectType, + isEnumType, + isScalarType, + isUnionType, +} from 'graphql'; +import { useDocExplorerActions } from '../context'; +import './type-card.css'; + +function getTypeKind(type: GraphQLNamedType): string { + if (isObjectType(type)) { + return 'TYPE'; + } + if (isInterfaceType(type)) { + return 'INTERFACE'; + } + if (isInputObjectType(type)) { + return 'INPUT'; + } + if (isEnumType(type)) { + return 'ENUM'; + } + if (isScalarType(type)) { + return 'SCALAR'; + } + if (isUnionType(type)) { + return 'UNION'; + } + return 'TYPE'; +} + +type TypeCardProps = { + type: GraphQLNamedType; +}; + +export const TypeCard: FC = ({ type }) => { + const { push } = useDocExplorerActions(); + const kind = getTypeKind(type); + const interfaces = isObjectType(type) ? type.getInterfaces() : []; + + return ( +
+
+ {kind} + + {type.name} + +
+ {type.description && ( +

+ {type.description} +

+ )} + {interfaces.length > 0 && ( +
+ + implements + + {interfaces.map((iface, i) => ( + + {i > 0 && ( + + )} + { + event.preventDefault(); + push({ name: iface.name, def: iface }); + }} + > + {iface.name} + + + ))} +
+ )} +
+ ); +}; diff --git a/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.css b/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.css index 7f65c236ba5..47b9f91a645 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.css +++ b/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.css @@ -7,5 +7,5 @@ } .graphiql-doc-explorer-enum-value { - color: hsl(var(--color-info)); + color: oklch(var(--accent-green-light)); } diff --git a/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.tsx b/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.tsx index 219ecf3488b..b2d010de80d 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/components/type-documentation.tsx @@ -24,16 +24,25 @@ type TypeDocumentationProps = { * The type that should be rendered. */ type: GraphQLNamedType; + /** + * When true, the description, implements-interfaces, and fields sections + * are omitted. Used when TypeCard + FieldsList are already rendering those + * above the type documentation. + */ + hideHeader?: boolean; }; -export const TypeDocumentation: FC = ({ type }) => { +export const TypeDocumentation: FC = ({ + type, + hideHeader, +}) => { return isNamedType(type) ? ( <> - {type.description ? ( + {!hideHeader && type.description ? ( {type.description} ) : null} - - + {!hideHeader && } + {!hideHeader && } diff --git a/packages/graphiql-plugin-doc-explorer/src/components/type-link.css b/packages/graphiql-plugin-doc-explorer/src/components/type-link.css index afd2048462d..d3278fb62d5 100644 --- a/packages/graphiql-plugin-doc-explorer/src/components/type-link.css +++ b/packages/graphiql-plugin-doc-explorer/src/components/type-link.css @@ -1,5 +1,5 @@ a.graphiql-doc-explorer-type-name { - color: hsl(var(--color-warning)); + color: oklch(var(--accent-orange)); text-decoration: none; &:hover { @@ -7,6 +7,6 @@ a.graphiql-doc-explorer-type-name { } &:focus { - outline: hsl(var(--color-warning)) auto 1px; + outline: oklch(var(--accent-orange)) auto 1px; } } diff --git a/packages/graphiql-plugin-doc-explorer/src/context.tsx b/packages/graphiql-plugin-doc-explorer/src/context.tsx index e0bb2c97a8a..91e12425063 100644 --- a/packages/graphiql-plugin-doc-explorer/src/context.tsx +++ b/packages/graphiql-plugin-doc-explorer/src/context.tsx @@ -96,7 +96,7 @@ export type DocExplorerStoreType = { }; }; -const INITIAL_NAV_STACK: DocExplorerNavStack = [{ name: 'Docs' }]; +const INITIAL_NAV_STACK: DocExplorerNavStack = [{ name: 'Root' }]; export const docExplorerStore = createStore( (set, get) => ({ diff --git a/packages/graphiql-plugin-doc-explorer/src/stories/doc-explorer.stories.tsx b/packages/graphiql-plugin-doc-explorer/src/stories/doc-explorer.stories.tsx new file mode 100644 index 00000000000..904ab8b8875 --- /dev/null +++ b/packages/graphiql-plugin-doc-explorer/src/stories/doc-explorer.stories.tsx @@ -0,0 +1,304 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + GraphQLBoolean, + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + GraphQLUnionType, +} from 'graphql'; +import { Tooltip } from '@graphiql/react'; +import { DocExplorerStore } from '../context'; +import { TypeDocumentation } from '../components/type-documentation'; +import { FieldDocumentation } from '../components/field-documentation'; +import { SchemaDocumentation } from '../components/schema-documentation'; +import { TypeCard } from '../components/type-card'; +import { FieldsList } from '../components/fields-list'; +import { Breadcrumb } from '../components/breadcrumb'; +import { TypeLink } from '../components/type-link'; +import { FieldLink } from '../components/field-link'; +import { Argument } from '../components/argument'; + +const EpisodeEnum = new GraphQLEnumType({ + name: 'Episode', + description: 'One of the films in the Star Wars Trilogy', + values: { + NEWHOPE: { value: 4, description: 'Released in 1977' }, + EMPIRE: { value: 5, description: 'Released in 1980' }, + JEDI: { + value: 6, + description: 'Released in 1983', + deprecationReason: 'Use RETURN_OF_THE_JEDI', + }, + }, +}); + +const CharacterInterface = new GraphQLInterfaceType({ + name: 'Character', + description: 'A character in the Star Wars universe', + fields: () => ({ + id: { + type: new GraphQLNonNull(GraphQLString), + description: 'Unique identifier', + }, + name: { type: GraphQLString, description: "The character's name" }, + appearsIn: { + type: new GraphQLList(EpisodeEnum), + description: 'Which films they appear in', + }, + }), +}); + +const ReviewInputType = new GraphQLInputObjectType({ + name: 'ReviewInput', + description: 'Input for creating a film review', + fields: { + stars: { + type: new GraphQLNonNull(GraphQLString), + description: 'Number of stars (1–5)', + }, + commentary: { + type: GraphQLString, + description: 'An optional explanation for the rating', + }, + }, +}); + +const HumanType = new GraphQLObjectType({ + name: 'Human', + description: 'A humanoid creature in the Star Wars universe', + interfaces: [CharacterInterface], + fields: () => ({ + id: { type: new GraphQLNonNull(GraphQLString), description: 'Unique ID' }, + name: { type: GraphQLString, description: 'Name of the human' }, + homePlanet: { + type: GraphQLString, + description: 'The home planet of the human, or null if unknown', + }, + appearsIn: { + type: new GraphQLList(EpisodeEnum), + description: 'Which films they appear in', + }, + friends: { + type: new GraphQLList(CharacterInterface), + description: 'The friends of the human', + deprecationReason: 'Use friendsConnection instead', + }, + }), +}); + +const SearchResultUnion = new GraphQLUnionType({ + name: 'SearchResult', + description: 'A result from a global search', + types: [HumanType], +}); + +const QueryType = new GraphQLObjectType({ + name: 'Query', + description: 'The root query type for the Star Wars API', + fields: { + hero: { + type: CharacterInterface, + description: 'Returns the hero of the Star Wars universe', + args: { + episode: { + type: EpisodeEnum, + description: + 'If provided, returns the hero of that particular episode', + }, + }, + }, + human: { + type: HumanType, + description: 'Returns a human character by ID', + args: { + id: { + type: new GraphQLNonNull(GraphQLString), + description: 'ID of the human to fetch', + }, + }, + }, + isAlive: { + type: GraphQLBoolean, + deprecationReason: 'Use `hero` with status field instead', + }, + search: { + type: SearchResultUnion, + description: 'Search across all types', + }, + }, +}); + +const MutationType = new GraphQLObjectType({ + name: 'Mutation', + fields: { + createReview: { + type: GraphQLBoolean, + description: 'Submit a review for a film', + args: { + episode: { type: EpisodeEnum }, + review: { type: new GraphQLNonNull(ReviewInputType) }, + }, + }, + }, +}); + +const StarWarsSchema = new GraphQLSchema({ + description: 'The Star Wars GraphQL API', + query: QueryType, + mutation: MutationType, + types: [ + HumanType, + CharacterInterface, + EpisodeEnum, + ReviewInputType, + SearchResultUnion, + ], +}); + +function withDocExplorerStore(Story: React.FC) { + return ( + + + + + + ); +} + +const meta: Meta = { + title: 'DocExplorer', + tags: ['autodocs'], + decorators: [withDocExplorerStore], +}; + +export default meta; + +type Story = StoryObj; + +export const SchemaOverview: Story = { + name: 'Schema overview', + render: function SchemaOverviewStory() { + return ; + }, +}; + +export const TypeDetailObject: Story = { + name: 'Type detail — Object', + render: function TypeDetailObjectStory() { + return ( + <> + + + + ); + }, +}; + +export const TypeDetailInterface: Story = { + name: 'Type detail — Interface', + render: function TypeDetailInterfaceStory() { + return ( + <> + + + + ); + }, +}; + +export const TypeDetailInput: Story = { + name: 'Type detail — Input', + render: function TypeDetailInputStory() { + return ( + <> + + + + ); + }, +}; + +export const TypeDetailEnum: Story = { + name: 'Type detail — Enum', + render: function TypeDetailEnumStory() { + return ( + <> + + + + ); + }, +}; + +export const FieldDetail: Story = { + name: 'Field detail', + render: function FieldDetailStory() { + const heroField = QueryType.getFields()['hero']!; + return ; + }, +}; + +export const BreadcrumbNav: Story = { + name: 'Breadcrumb navigation', + render: function BreadcrumbNavStory() { + const navStack: Parameters[0]['navStack'] = [ + { name: 'Root' }, + { name: 'Query', def: QueryType }, + { name: 'Human', def: HumanType }, + ]; + return {}} />; + }, +}; + +export const TokenColors: Story = { + name: 'Token colors', + render: function TokenColorsStory() { + const heroField = QueryType.getFields()['hero']!; + const episodeArg = heroField.args[0]!; + return ( +
+
+
+ Type link +
+ +
+
+
+ Field link +
+ +
+
+
+ Argument +
+ +
+
+ ); + }, +}; diff --git a/packages/graphiql-plugin-explorer/README.md b/packages/graphiql-plugin-explorer/README.md index ea75f273f10..cd22b91cbfa 100644 --- a/packages/graphiql-plugin-explorer/README.md +++ b/packages/graphiql-plugin-explorer/README.md @@ -21,12 +21,12 @@ npm install react react-dom graphql ```jsx import { GraphiQL } from 'graphiql'; -import { createGraphiQLFetcher } from '@graphiql/toolkit'; +import { createTransport } from '@graphiql/toolkit'; import { explorerPlugin } from '@graphiql/plugin-explorer'; import 'graphiql/style.css'; import '@graphiql/plugin-explorer/style.css'; -const fetcher = createGraphiQLFetcher({ +const transport = createTransport({ url: 'https://swapi-graphql.netlify.app/.netlify/functions/index', }); @@ -34,7 +34,7 @@ const fetcher = createGraphiQLFetcher({ const explorer = explorerPlugin(); function GraphiQLWithExplorer() { - return ; + return ; } ``` diff --git a/packages/graphiql-plugin-history/src/components.tsx b/packages/graphiql-plugin-history/src/components.tsx index 1710eaf6ee7..1d594430f01 100644 --- a/packages/graphiql-plugin-history/src/components.tsx +++ b/packages/graphiql-plugin-history/src/components.tsx @@ -10,22 +10,13 @@ import { useGraphiQL, pick, Button, + MethodPill, Tooltip, UnStyledButton, + PanelHeader, } from '@graphiql/react'; import { useHistory, useHistoryActions } from './context'; -// Fix error from react-compiler -// Support value blocks (conditional, logical, optional chaining, etc.) within a try/catch statement -function handleDelete( - items: QueryStoreItem[], - deleteFromHistory: ReturnType['deleteFromHistory'], -) { - for (const item of items) { - deleteFromHistory(item, true); - } -} - export const History: FC = () => { const all = useHistory(); const { deleteFromHistory } = useHistoryActions(); @@ -41,47 +32,27 @@ export const History: FC = () => { items = items.filter(item => !item.favorite); } - const [clearStatus, setClearStatus] = useState<'success' | 'error' | null>( - null, - ); - useEffect(() => { - if (clearStatus) { - // reset the button after a couple seconds - setTimeout(() => { - setClearStatus(null); - }, 2000); - } - }, [clearStatus]); - - const handleClearStatus = () => { - try { - handleDelete(items, deleteFromHistory); - setClearStatus('success'); - } catch { - setClearStatus('error'); + const handleClear = () => { + for (const item of items) { + deleteFromHistory(item, true); } }; const hasFavorites = Boolean(favorites.length); const hasItems = Boolean(items.length); + const clearButton = hasItems ? ( + + ) : undefined; + return (
-
- History - {(clearStatus || hasItems) && ( - - )} -
+ {hasFavorites && (
    @@ -166,6 +137,10 @@ export const HistoryItem: FC = props => { toggleFavorite(props.item); }; + const variablesSnippet = props.item.variables + ? formatVariables(props.item.variables) + : null; + return (
  • {isEditable ? ( @@ -193,54 +168,70 @@ export const HistoryItem: FC = props => { ) : ( <> - - - {displayName} - - - - - - - - - {props.item.favorite ? ( -
  • @@ -256,3 +247,19 @@ export function formatQuery(query?: string) { .replaceAll('}', ' } ') .replaceAll(/[\s]{2,}/g, ' '); } + +export function formatVariables(variables: string) { + try { + const parsed = JSON.parse(variables) as Record; + const entries = Object.entries(parsed); + if (!entries.length) { + return null; + } + return entries + .slice(0, 3) + .map(([k, v]) => `${k}: ${JSON.stringify(v)}`) + .join(', '); + } catch { + return variables.slice(0, 60); + } +} diff --git a/packages/graphiql-plugin-history/src/history.stories.tsx b/packages/graphiql-plugin-history/src/history.stories.tsx new file mode 100644 index 00000000000..2dc02899bcc --- /dev/null +++ b/packages/graphiql-plugin-history/src/history.stories.tsx @@ -0,0 +1,287 @@ +import type { ReactNode } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + GraphiQLProvider, + Tooltip, + PanelHeader, + Button, +} from '@graphiql/react'; +import { History, HistoryItem } from './components'; +import { HistoryStore } from './context'; +import './style.css'; + +// --------------------------------------------------------------------------- +// Shared decorator: History items need GraphiQLProvider + HistoryStore +// --------------------------------------------------------------------------- + +function withHistoryContext(children: ReactNode) { + return ( + + ({ + ok: true, + body: { data: {} }, + timing: { totalMs: 0 }, + size: {}, + }), + }} + > + +
    + {children} +
    +
    +
    +
    + ); +} + +// --------------------------------------------------------------------------- +// Meta +// --------------------------------------------------------------------------- + +const meta: Meta = { + title: 'Plugins/History', + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +// --------------------------------------------------------------------------- +// PanelHeader (no context required) +// --------------------------------------------------------------------------- + +export const Header: Story = { + render: () => ( +
    + + Clear + + } + /> +
    + ), +}; + +// --------------------------------------------------------------------------- +// Single item — query only +// --------------------------------------------------------------------------- + +export const ItemQueryOnly: Story = { + render: () => + withHistoryContext( +
      + +
    , + ), +}; + +// --------------------------------------------------------------------------- +// Single item — with variables snippet +// --------------------------------------------------------------------------- + +export const ItemWithVariables: Story = { + render: () => + withHistoryContext( +
      + +
    , + ), +}; + +// --------------------------------------------------------------------------- +// Favorite item +// --------------------------------------------------------------------------- + +export const ItemFavorite: Story = { + render: () => + withHistoryContext( +
      + +
    , + ), +}; + +// --------------------------------------------------------------------------- +// Custom label +// --------------------------------------------------------------------------- + +export const ItemWithLabel: Story = { + render: () => + withHistoryContext( +
      + +
    , + ), +}; + +// --------------------------------------------------------------------------- +// Empty state (full History component) +// --------------------------------------------------------------------------- + +export const Empty: Story = { + render: () => withHistoryContext(), +}; + +// --------------------------------------------------------------------------- +// Few rows +// --------------------------------------------------------------------------- + +export const FewRows: Story = { + render: () => + withHistoryContext( +
      + + + +
    , + ), +}; + +// --------------------------------------------------------------------------- +// Many rows +// --------------------------------------------------------------------------- + +const manyItems = Array.from({ length: 10 }, (_, i) => ({ + query: `query Query${i + 1} { node(id: "${i + 1}") { id ... on User { name } } }`, + operationName: `Query${i + 1}`, + operation: 'query' as const, + favorite: false, +})); + +export const ManyRows: Story = { + render: () => + withHistoryContext( +
      + {manyItems.map(item => ( + + ))} +
    , + ), +}; + +// --------------------------------------------------------------------------- +// Mixed: favorites + regular + with variables +// --------------------------------------------------------------------------- + +export const Mixed: Story = { + render: () => + withHistoryContext( + <> +
      + + +
    +
    +
      + + + +
    + , + ), +}; diff --git a/packages/graphiql-plugin-history/src/style.css b/packages/graphiql-plugin-history/src/style.css index ac536135f29..dea382f17e3 100644 --- a/packages/graphiql-plugin-history/src/style.css +++ b/packages/graphiql-plugin-history/src/style.css @@ -1,44 +1,29 @@ -.graphiql-history-header { - font-size: var(--font-size-h2); - font-weight: var(--font-weight-medium); +.graphiql-history { display: flex; - justify-content: space-between; - align-items: center; -} - -.graphiql-history-header button { - font-size: var(--font-size-inline-code); - padding: var(--px-6) var(--px-10); + flex-direction: column; + height: 100%; + overflow: hidden; } .graphiql-history-items { - margin: var(--px-16) 0 0; + margin: 0; list-style: none; padding: 0; + overflow-y: auto; + flex: 1; } .graphiql-history-item { - border-radius: var(--border-radius-4); - color: hsla(var(--color-neutral), var(--alpha-secondary)); + border-bottom: 1px solid oklch(var(--border-default)); display: flex; - font-size: var(--font-size-inline-code); - font-family: var(--font-family-mono); - height: 34px; + align-items: stretch; &:hover { - color: hsl(var(--color-neutral)); - background-color: hsla(var(--color-neutral), var(--alpha-background-light)); - } - - &:not(:first-child) { - margin-top: var(--px-4); + background-color: oklch(var(--accent-blue) / 0.06); } &.editable { - background-color: hsla( - var(--color-primary), - var(--alpha-background-medium) - ); + background-color: oklch(var(--accent-blue) / 0.08); & > input { background: transparent; @@ -46,27 +31,27 @@ flex: 1; margin: 0; outline: none; - padding: 0 var(--px-10); + padding: 0 var(--px-14); width: 100%; + color: oklch(var(--fg-default)); + font-family: var(--font-family-mono); + font-size: var(--font-size-mono); &::placeholder { - color: hsla(var(--color-neutral), var(--alpha-secondary)); + color: oklch(var(--fg-disabled)); } } & > button { - color: hsl(var(--color-primary)); + color: oklch(var(--accent-blue)); padding: 0 var(--px-10); &:active { - background-color: hsla( - var(--color-primary), - var(--alpha-background-heavy) - ); + background-color: oklch(var(--accent-blue) / 0.12); } &:focus { - outline: hsl(var(--color-primary)) auto 1px; + outline: oklch(var(--accent-blue)) auto 1px; } & > svg { @@ -76,30 +61,80 @@ } } -button.graphiql-history-item-label { +.graphiql-history-item-inner { + padding: var(--px-8) var(--px-14); + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.graphiql-history-item-row { + display: flex; + align-items: center; + gap: var(--px-6); + min-width: 0; +} + +.graphiql-history-item-label { flex: 1; - padding: var(--px-8) var(--px-10); + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: oklch(var(--fg-default)); + font-family: var(--font-family-mono); + font-size: var(--font-size-mono); + text-align: left; +} + +.graphiql-history-item-meta { + display: flex; + align-items: center; + gap: var(--px-6); + min-width: 0; + padding-left: 12px; +} + +.graphiql-history-item-variables { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: oklch(var(--fg-subtle)); + font-family: var(--font-family-mono); + font-size: var(--font-size-eyebrow); +} + +.graphiql-history-item-actions { + flex-shrink: 0; + display: flex; + align-items: center; + padding-right: var(--px-6); } -button.graphiql-history-item-action { +.graphiql-history-item-action { align-items: center; - color: hsla(var(--color-neutral), var(--alpha-secondary)); + color: oklch(var(--fg-disabled)); display: flex; - padding: var(--px-8) var(--px-6); + padding: var(--px-6); + border-radius: var(--radius-sm); &:hover { - color: hsl(var(--color-neutral)); + color: oklch(var(--fg-default)); + background-color: oklch(var(--fg-default) / 0.06); } & > svg { - height: 14px; - width: 14px; + height: 12px; + width: 12px; } } .graphiql-history-item-spacer { - height: var(--px-16); + height: 1px; + background-color: oklch(var(--border-strong)); + margin: var(--px-8) 0; } diff --git a/packages/graphiql-react/.storybook/main.ts b/packages/graphiql-react/.storybook/main.ts new file mode 100644 index 00000000000..cbe952ed5bf --- /dev/null +++ b/packages/graphiql-react/.storybook/main.ts @@ -0,0 +1,12 @@ +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + stories: [ + '../src/**/*.stories.@(ts|tsx)', + '../../graphiql-plugin-history/src/**/*.stories.@(ts|tsx)', + ], + addons: ['@storybook/addon-a11y', '@storybook/addon-vitest'], + framework: { name: '@storybook/react-vite', options: {} }, +}; + +export default config; diff --git a/packages/graphiql-react/.storybook/preview.tsx b/packages/graphiql-react/.storybook/preview.tsx new file mode 100644 index 00000000000..614f0206d5f --- /dev/null +++ b/packages/graphiql-react/.storybook/preview.tsx @@ -0,0 +1,81 @@ +import { useEffect } from 'react'; +import type { Preview } from '@storybook/react-vite'; +import '../src/style/root.css'; + +const preview: Preview = { + parameters: { + backgrounds: { disable: true }, + a11y: { test: 'error' }, + }, + globalTypes: { + theme: { + description: 'Theme', + defaultValue: 'dark', + toolbar: { + icon: 'circlehollow', + items: [ + { value: 'dark', title: 'Dark' }, + { value: 'light', title: 'Light' }, + ], + dynamicTitle: true, + }, + }, + density: { + description: 'Density', + defaultValue: 'comfortable', + toolbar: { + icon: 'expand', + items: [ + { value: 'compact', title: 'Compact' }, + { value: 'comfortable', title: 'Comfortable' }, + { value: 'spacious', title: 'Spacious' }, + ], + dynamicTitle: true, + }, + }, + fontSize: { + description: 'Font size', + defaultValue: 'default', + toolbar: { + icon: 'beaker', + items: [ + { value: 'compact', title: 'Compact' }, + { value: 'default', title: 'Default' }, + { value: 'large', title: 'Large' }, + { value: 'xl', title: 'Extra large' }, + ], + dynamicTitle: true, + }, + }, + }, + decorators: [ + (Story, ctx) => { + useEffect(() => { + const root = document.documentElement; + root.setAttribute('data-theme', ctx.globals.theme); + root.setAttribute('data-density', ctx.globals.density); + root.setAttribute('data-font-size', ctx.globals.fontSize); + document.body.style.background = 'oklch(var(--bg-canvas))'; + document.body.style.color = 'oklch(var(--fg-default))'; + document.body.style.margin = '0'; + }, [ctx.globals.theme, ctx.globals.density, ctx.globals.fontSize]); + + return ( +
    + +
    + ); + }, + ], +}; + +export default preview; diff --git a/packages/graphiql-react/README.md b/packages/graphiql-react/README.md index b04674405e9..e4fd99abb29 100644 --- a/packages/graphiql-react/README.md +++ b/packages/graphiql-react/README.md @@ -22,21 +22,21 @@ All the state for your GraphQL IDE lives in multiple contexts. The easiest way to get started is by using the `GraphiQLProvider` component that renders all the individual providers. -There is one required prop called `fetcher`. This is a function that performs -GraphQL request against a given endpoint. You can easily create a fetcher using -the method `createGraphiQLFetcher` from the `@graphiql/toolkit` package. +There is one required prop called `transport`. This is a function that performs +GraphQL requests against a given endpoint. You can easily create a transport using +the method `createTransport` from the `@graphiql/toolkit` package. ```jsx import { GraphiQLProvider } from '@graphiql/react'; -import { createGraphiQLFetcher } from '@graphiql/toolkit'; +import { createTransport } from '@graphiql/toolkit'; -const fetcher = createGraphiQLFetcher({ +const transport = createTransport({ url: 'https://my.graphql.api/graphql', }); function MyGraphQLIDE() { return ( - +
    Hello GraphQL
    ); @@ -51,7 +51,7 @@ import { QueryEditor } from '@graphiql/react'; function MyGraphQLIDE() { return ( - +
    diff --git a/packages/graphiql-react/package.json b/packages/graphiql-react/package.json index 261779ab8a6..b8cf3068157 100644 --- a/packages/graphiql-react/package.json +++ b/packages/graphiql-react/package.json @@ -42,6 +42,8 @@ "types:check": "tsgo --noEmit", "dev": "vite build --watch --emptyOutDir=false", "build": "vite build", + "build-storybook": "storybook build", + "storybook": "storybook dev -p 6006", "test": "vitest run --typecheck" }, "peerDependencies": { @@ -70,15 +72,23 @@ }, "devDependencies": { "@babel/helper-string-parser": "^7.19.4", + "@storybook/addon-a11y": "^10.3.6", + "@storybook/addon-vitest": "^10.3.6", + "@storybook/react-vite": "^10.3.6", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", "@types/get-value": "^3.0.5", "@types/markdown-it": "^14.1.2", "@types/react-dom": "^19.1.2", "@types/set-value": "^4.0.1", "@vitejs/plugin-react": "^4.4.1", + "@vitest/browser-playwright": "^4.1.6", "babel-plugin-react-compiler": "19.1.0-rc.1", "graphql": "^16.9.0", + "playwright": "^1.60.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "storybook": "^10.3.6", "typescript": "^5.8.0", "vite": "^6.3.4", "vite-plugin-dts": "^4.5.3", diff --git a/packages/graphiql-react/setup-files.ts b/packages/graphiql-react/setup-files.ts index 984dec5f9af..0d89e8954dc 100644 --- a/packages/graphiql-react/setup-files.ts +++ b/packages/graphiql-react/setup-files.ts @@ -1,6 +1,30 @@ -import { vi } from 'vitest'; +import { vi, afterEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; + +// Node 24 + jsdom defines `window` but not `localStorage`. Polyfill with a +// minimal in-memory implementation so StorageAPI can construct without error. +if (typeof localStorage === 'undefined') { + const store = new Map(); + globalThis.localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem(key: string, value: string) { + store.set(key, value); + }, + removeItem(key: string) { + store.delete(key); + }, + clear() { + store.clear(); + }, + get length() { + return store.size; + }, + key: (index: number) => [...store.keys()][index] ?? null, + }; +} + +afterEach(cleanup); // to make it works like Jest (auto-mocking) vi.mock('monaco-editor'); - -export {}; diff --git a/packages/graphiql-react/src/components/activity-rail/activity-rail.stories.tsx b/packages/graphiql-react/src/components/activity-rail/activity-rail.stories.tsx new file mode 100644 index 00000000000..8ebcf7ab1fb --- /dev/null +++ b/packages/graphiql-react/src/components/activity-rail/activity-rail.stories.tsx @@ -0,0 +1,117 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { + DocsIcon, + HistoryIcon, + StarIcon, + MagnifyingGlassIcon, + SettingsIcon, +} from '../../icons'; +import { Tooltip } from '../tooltip'; +import './index.css'; + +type Plugin = { title: string; icon: React.ComponentType }; + +const SAMPLE_PLUGINS: Plugin[] = [ + { title: 'Documentation Explorer', icon: DocsIcon }, + { title: 'History', icon: HistoryIcon }, + { title: 'Favorites', icon: StarIcon }, + { title: 'Search', icon: MagnifyingGlassIcon }, +]; + +function ActivityRailDemo({ + initialActive, + showSettings = true, +}: { + initialActive?: string; + showSettings?: boolean; +}) { + const [active, setActive] = useState(initialActive ?? null); + + function toggle(title: string) { + setActive(prev => (prev === title ? null : title)); + } + + return ( + +
    + + {active && ( +
    + {active} panel +
    + )} +
    +
    + ); +} + +const meta: Meta = { + title: 'Components/ActivityRail', + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; + +export const Default: StoryObj = { + render: () => , +}; + +export const WithActivePlugin: StoryObj = { + render: () => , +}; + +export const NoPluginsNoSettings: StoryObj = { + render: () => ( + +
    + +
    +
    + ), +}; diff --git a/packages/graphiql-react/src/components/activity-rail/activity-rail.test.tsx b/packages/graphiql-react/src/components/activity-rail/activity-rail.test.tsx new file mode 100644 index 00000000000..e171336275c --- /dev/null +++ b/packages/graphiql-react/src/components/activity-rail/activity-rail.test.tsx @@ -0,0 +1,132 @@ +'use no memo'; + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as T from '@radix-ui/react-tooltip'; +import type { ReactNode } from 'react'; +import type { GraphiQLPlugin } from '../../stores/plugin'; +import { ActivityRail } from './'; + +const DocsIcon = () => ; +const HistoryIcon = () => ; + +const DOCS_PLUGIN: GraphiQLPlugin = { + title: 'Documentation Explorer', + icon: DocsIcon, + content: () => null, +}; + +const HISTORY_PLUGIN: GraphiQLPlugin = { + title: 'History', + icon: HistoryIcon, + content: () => null, +}; + +const mockSetVisiblePlugin = vi.fn(); + +vi.mock('../provider', () => ({ + useGraphiQL: vi.fn(), + useGraphiQLActions: vi.fn(), +})); + +import { useGraphiQL, useGraphiQLActions } from '../provider'; + +const mockUseGraphiQL = vi.mocked(useGraphiQL); +const mockUseGraphiQLActions = vi.mocked(useGraphiQLActions); + +function renderWithProvider(ui: ReactNode) { + return render({ui}); +} + +function setup(visiblePlugin: GraphiQLPlugin | null = null) { + mockUseGraphiQL.mockImplementation((selector: (s: any) => any) => { + const state = { + plugins: [DOCS_PLUGIN, HISTORY_PLUGIN], + visiblePlugin, + }; + return selector(state); + }); + mockUseGraphiQLActions.mockReturnValue({ + setVisiblePlugin: mockSetVisiblePlugin, + } as any); +} + +describe('ActivityRail', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders a button for each plugin', () => { + setup(); + renderWithProvider(); + expect( + screen.getByRole('button', { name: 'Show Documentation Explorer' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Show History' }), + ).toBeInTheDocument(); + }); + + it('marks the active plugin with aria-pressed=true', () => { + setup(DOCS_PLUGIN); + renderWithProvider(); + expect( + screen.getByRole('button', { name: 'Hide Documentation Explorer' }), + ).toHaveAttribute('aria-pressed', 'true'); + expect( + screen.getByRole('button', { name: 'Show History' }), + ).toHaveAttribute('aria-pressed', 'false'); + }); + + it('calls setVisiblePlugin with the plugin when an inactive button is clicked', async () => { + const user = userEvent.setup(); + setup(null); + renderWithProvider(); + await user.click( + screen.getByRole('button', { name: 'Show Documentation Explorer' }), + ); + expect(mockSetVisiblePlugin).toHaveBeenCalledWith(DOCS_PLUGIN); + }); + + it('calls setVisiblePlugin(null) when the active plugin button is clicked', async () => { + const user = userEvent.setup(); + setup(DOCS_PLUGIN); + renderWithProvider(); + await user.click( + screen.getByRole('button', { name: 'Hide Documentation Explorer' }), + ); + expect(mockSetVisiblePlugin).toHaveBeenCalledWith(null); + }); + + it('renders inside a