diff options
Diffstat (limited to 'source/library/components')
| -rw-r--r-- | source/library/components/DocExplorer.svelte | 226 | ||||
| -rw-r--r-- | source/library/components/DocExplorer/FieldView.svelte | 87 | ||||
| -rw-r--r-- | source/library/components/DocExplorer/TypeLink.svelte | 42 | ||||
| -rw-r--r-- | source/library/components/DocExplorer/TypeView.svelte | 199 | ||||
| -rw-r--r-- | source/library/components/Editor.svelte | 136 | ||||
| -rw-r--r-- | source/library/components/HeadersEditor.svelte | 34 | ||||
| -rw-r--r-- | source/library/components/HistoryPanel.svelte | 187 | ||||
| -rw-r--r-- | source/library/components/ResultViewer.svelte | 35 | ||||
| -rw-r--r-- | source/library/components/TabBar.svelte | 161 | ||||
| -rw-r--r-- | source/library/components/Toolbar.svelte | 150 |
10 files changed, 1257 insertions, 0 deletions
diff --git a/source/library/components/DocExplorer.svelte b/source/library/components/DocExplorer.svelte new file mode 100644 index 0000000..536cb2a --- /dev/null +++ b/source/library/components/DocExplorer.svelte @@ -0,0 +1,226 @@ +<script lang="ts"> + import FieldView from "./DocExplorer/FieldView.svelte"; + import TypeView from "./DocExplorer/TypeView.svelte"; + import { + isInputObjectType, + isInterfaceType, + isObjectType + } from "graphql"; + import type { + GraphQLField, + GraphQLInputField, + GraphQLNamedType, + GraphQLSchema + } from "graphql"; + + type NavEntry = + | { kind: "field"; fieldName: string; typeName: string } + | { kind: "type"; name: string }; + + type Props = { + schema: GraphQLSchema; + }; + + let { schema }: Props = $props(); + + let stack = $state<NavEntry[]>([]); + + const current = $derived<NavEntry | null>(stack.length > 0 ? stack[stack.length - 1] : null); + + const rootTypes = $derived.by(() => { + const out: { label: string; type: GraphQLNamedType }[] = []; + const q = schema.getQueryType(); + const m = schema.getMutationType(); + const s = schema.getSubscriptionType(); + + if (q) + out.push({ label: "Query", type: q }); + + if (m) + out.push({ label: "Mutation", type: m }); + + if (s) + out.push({ label: "Subscription", type: s }); + + return out; + }); + + const currentType = $derived.by<GraphQLNamedType | null>(() => { + if (!current) + return null; + + const name = current.kind === "type" ? current.name : current.typeName; + return schema.getType(name) ?? null; + }); + + const currentField = $derived.by<GraphQLField<unknown, unknown> | GraphQLInputField | null>(() => { + if (!current || current.kind !== "field" || !currentType) + return null; + + if (isObjectType(currentType) || isInterfaceType(currentType) || isInputObjectType(currentType)) + return currentType.getFields()[current.fieldName] ?? null; + + return null; + }); + + function crumbLabel(entry: NavEntry): string { + return entry.kind === "type" ? entry.name : entry.fieldName; + } + + function gotoRoot() { + stack = []; + } + + function gotoIndex(index: number) { + stack = stack.slice(0, index + 1); + } + + function pushType(name: string) { + if (!schema.getType(name)) + return; + + stack = [...stack, { kind: "type", name }]; + } + + function pushField(fieldName: string) { + const typeName = current?.kind === "type" ? current.name : null; + + if (!typeName) + return; + + stack = [...stack, { fieldName, kind: "field", typeName }]; + } +</script> + +<style lang="scss"> + .explorer { + background: var(--graphiql-panel, #252526); + border-left: 1px solid var(--graphiql-border, #333); + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + min-height: 0; + overflow: hidden; + } + + .breadcrumbs { + align-items: center; + border-bottom: 1px solid var(--graphiql-border, #333); + display: flex; + flex-wrap: wrap; + font-size: 0.8125rem; + gap: 0.25rem; + padding: 0.5rem 0.75rem; + } + + .crumb { + background: none; + border: none; + color: var(--graphiql-link, #79b8ff); + cursor: pointer; + font-family: inherit; + font-size: inherit; + padding: 0; + + &:hover { + text-decoration: underline; + } + + &.current { + color: var(--graphiql-fg, #d4d4d4); + cursor: default; + + &:hover { + text-decoration: none; + } + } + } + + .separator { + color: var(--graphiql-muted, #858585); + } + + .body { + min-height: 0; + overflow-y: auto; + } + + .root { + display: grid; + gap: 0.75rem; + padding: 0.75rem 1rem; + } + + .section-label { + color: var(--graphiql-muted, #858585); + font-size: 0.7rem; + letter-spacing: 0.05em; + margin-bottom: 0.25rem; + text-transform: uppercase; + } + + .root-list { + display: grid; + gap: 0.375rem; + } + + .root-link { + background: none; + border: none; + color: var(--graphiql-link, #79b8ff); + cursor: pointer; + font-family: inherit; + font-size: 0.875rem; + padding: 0; + text-align: left; + + &:hover { + text-decoration: underline; + } + } + + .empty { + color: var(--graphiql-muted, #858585); + font-size: 0.8125rem; + padding: 0.75rem 1rem; + } +</style> + +<div class="explorer"> + <div class="breadcrumbs"> + <button + class="crumb" + class:current={stack.length === 0} + onclick={gotoRoot}>Docs</button> + {#each stack as entry, i} + <span class="separator">/</span> + <button + class="crumb" + class:current={i === stack.length - 1} + onclick={() => gotoIndex(i)}>{crumbLabel(entry)}</button> + {/each} + </div> + <div class="body"> + {#if stack.length === 0} + <div class="root"> + <div class="section-label">Root Types</div> + <div class="root-list"> + {#each rootTypes as entry} + <button class="root-link" onclick={() => pushType(entry.type.name)}> + {entry.label}: {entry.type.name} + </button> + {/each} + </div> + </div> + {:else if currentField} + <FieldView field={currentField} onNavigate={pushType}/> + {:else if currentType} + <TypeView + onNavigateField={pushField} + onNavigateType={pushType} + type={currentType}/> + {:else} + <div class="empty">Type not found in schema.</div> + {/if} + </div> +</div> diff --git a/source/library/components/DocExplorer/FieldView.svelte b/source/library/components/DocExplorer/FieldView.svelte new file mode 100644 index 0000000..71d215c --- /dev/null +++ b/source/library/components/DocExplorer/FieldView.svelte @@ -0,0 +1,87 @@ +<script lang="ts"> + import TypeLink from "./TypeLink.svelte"; + import type { GraphQLField, GraphQLInputField } from "graphql"; + + type Props = { + field: GraphQLField<unknown, unknown> | GraphQLInputField; + onNavigate: (typeName: string) => void; + }; + + let { field, onNavigate }: Props = $props(); + + const args = $derived("args" in field ? field.args : []); +</script> + +<style lang="scss"> + .field { + display: grid; + gap: 0.75rem; + padding: 0.75rem 1rem; + } + + .heading { + font-size: 0.95rem; + font-weight: 600; + } + + .section-label { + color: var(--graphiql-muted, #858585); + font-size: 0.7rem; + letter-spacing: 0.05em; + margin-bottom: 0.25rem; + text-transform: uppercase; + } + + .description { + color: var(--graphiql-muted, #858585); + font-size: 0.8125rem; + line-height: 1.4; + } + + .args { + display: grid; + gap: 0.375rem; + } + + .arg { + font-size: 0.8125rem; + } + + .arg-name { + color: var(--graphiql-fg, #d4d4d4); + } + + .arg-description { + color: var(--graphiql-muted, #858585); + font-size: 0.75rem; + margin-left: 1rem; + margin-top: 0.125rem; + } +</style> + +<div class="field"> + <div class="heading">{field.name}</div> + {#if field.description} + <div class="description">{field.description}</div> + {/if} + <div> + <div class="section-label">Type</div> + <TypeLink {onNavigate} type={field.type}/> + </div> + {#if args.length > 0} + <div> + <div class="section-label">Arguments</div> + <div class="args"> + {#each args as arg} + <div class="arg"> + <span class="arg-name">{arg.name}</span>: + <TypeLink {onNavigate} type={arg.type}/> + {#if arg.description} + <div class="arg-description">{arg.description}</div> + {/if} + </div> + {/each} + </div> + </div> + {/if} +</div> diff --git a/source/library/components/DocExplorer/TypeLink.svelte b/source/library/components/DocExplorer/TypeLink.svelte new file mode 100644 index 0000000..253d16e --- /dev/null +++ b/source/library/components/DocExplorer/TypeLink.svelte @@ -0,0 +1,42 @@ +<script lang="ts"> + import { getNamedType, isListType, isNonNullType } from "graphql"; + import type { GraphQLType } from "graphql"; + + type Props = { + onNavigate: (typeName: string) => void; + type: GraphQLType; + }; + + let { onNavigate, type }: Props = $props(); + + function label(t: GraphQLType): string { + if (isNonNullType(t)) + return `${label(t.ofType)}!`; + + if (isListType(t)) + return `[${label(t.ofType)}]`; + + return t.name; + } + + const named = $derived(getNamedType(type)); + const text = $derived(label(type)); +</script> + +<style lang="scss"> + .link { + background: none; + border: none; + color: var(--graphiql-link, #79b8ff); + cursor: pointer; + font-family: inherit; + font-size: inherit; + padding: 0; + + &:hover { + text-decoration: underline; + } + } +</style> + +<button class="link" onclick={() => onNavigate(named.name)}>{text}</button> diff --git a/source/library/components/DocExplorer/TypeView.svelte b/source/library/components/DocExplorer/TypeView.svelte new file mode 100644 index 0000000..31a1ca3 --- /dev/null +++ b/source/library/components/DocExplorer/TypeView.svelte @@ -0,0 +1,199 @@ +<script lang="ts"> + import TypeLink from "./TypeLink.svelte"; + import { + isEnumType, + isInputObjectType, + isInterfaceType, + isObjectType, + isScalarType, + isUnionType + } from "graphql"; + import type { GraphQLNamedType } from "graphql"; + + type Props = { + onNavigateField: (fieldName: string) => void; + onNavigateType: (typeName: string) => void; + type: GraphQLNamedType; + }; + + let { onNavigateField, onNavigateType, type }: Props = $props(); + + const fields = $derived.by(() => { + if (isObjectType(type) || isInterfaceType(type) || isInputObjectType(type)) + return Object.values(type.getFields()); + + return []; + }); + + const interfaces = $derived.by(() => { + if (isObjectType(type) || isInterfaceType(type)) + return type.getInterfaces(); + + return []; + }); + + const unionMembers = $derived.by(() => { + if (isUnionType(type)) + return type.getTypes(); + + return []; + }); + + const enumValues = $derived.by(() => { + if (isEnumType(type)) + return type.getValues(); + + return []; + }); + + const kindLabel = $derived.by(() => { + if (isObjectType(type)) + return "type"; + + if (isInterfaceType(type)) + return "interface"; + + if (isUnionType(type)) + return "union"; + + if (isEnumType(type)) + return "enum"; + + if (isInputObjectType(type)) + return "input"; + + if (isScalarType(type)) + return "scalar"; + + return ""; + }); +</script> + +<style lang="scss"> + .type { + display: grid; + gap: 0.75rem; + padding: 0.75rem 1rem; + } + + .heading { + font-size: 0.95rem; + font-weight: 600; + } + + .kind { + color: var(--graphiql-muted, #858585); + font-weight: normal; + margin-right: 0.375rem; + } + + .description { + color: var(--graphiql-muted, #858585); + font-size: 0.8125rem; + line-height: 1.4; + } + + .section-label { + color: var(--graphiql-muted, #858585); + font-size: 0.7rem; + letter-spacing: 0.05em; + margin-bottom: 0.25rem; + text-transform: uppercase; + } + + .list { + display: grid; + gap: 0.375rem; + } + + .entry { + font-size: 0.8125rem; + } + + .field-button { + background: none; + border: none; + color: var(--graphiql-fg, #d4d4d4); + cursor: pointer; + font-family: inherit; + font-size: inherit; + padding: 0; + + &:hover { + text-decoration: underline; + } + } + + .entry-description { + color: var(--graphiql-muted, #858585); + font-size: 0.75rem; + margin-left: 1rem; + margin-top: 0.125rem; + } +</style> + +<div class="type"> + <div class="heading"> + {#if kindLabel}<span class="kind">{kindLabel}</span>{/if}{type.name} + </div> + {#if type.description} + <div class="description">{type.description}</div> + {/if} + {#if interfaces.length > 0} + <div> + <div class="section-label">Implements</div> + <div class="list"> + {#each interfaces as iface} + <div class="entry"> + <TypeLink onNavigate={onNavigateType} type={iface}/> + </div> + {/each} + </div> + </div> + {/if} + {#if fields.length > 0} + <div> + <div class="section-label">Fields</div> + <div class="list"> + {#each fields as field} + <div class="entry"> + <button + class="field-button" + onclick={() => onNavigateField(field.name)}>{field.name}</button>: + <TypeLink onNavigate={onNavigateType} type={field.type}/> + {#if field.description} + <div class="entry-description">{field.description}</div> + {/if} + </div> + {/each} + </div> + </div> + {/if} + {#if unionMembers.length > 0} + <div> + <div class="section-label">Members</div> + <div class="list"> + {#each unionMembers as member} + <div class="entry"> + <TypeLink onNavigate={onNavigateType} type={member}/> + </div> + {/each} + </div> + </div> + {/if} + {#if enumValues.length > 0} + <div> + <div class="section-label">Values</div> + <div class="list"> + {#each enumValues as value} + <div class="entry"> + <span>{value.name}</span> + {#if value.description} + <div class="entry-description">{value.description}</div> + {/if} + </div> + {/each} + </div> + </div> + {/if} +</div> diff --git a/source/library/components/Editor.svelte b/source/library/components/Editor.svelte new file mode 100644 index 0000000..f2bf82d --- /dev/null +++ b/source/library/components/Editor.svelte @@ -0,0 +1,136 @@ +<script lang="ts"> + /*** IMPORT ------------------------------------------- ***/ + + import { browser } from "$app/environment"; + import { onMount } from "svelte"; + import type { Extension } from "@codemirror/state"; + import type { EditorView } from "@codemirror/view"; + import type { GraphQLSchema } from "graphql"; + + /*** UTILITY ------------------------------------------ ***/ + + type Props = { + language?: "graphql" | "json"; + onChange: (value: string) => void; + readOnly?: boolean; + schema?: string; + theme?: Extension; + value: string; + }; + + let { + language = "graphql", + onChange, + readOnly = false, + schema, + theme, + value + }: Props = $props(); + + let buildSchemaFn = $state<((sdl: string) => GraphQLSchema) | null>(null); + let container: HTMLDivElement; + let updateSchemaFn = $state<((v: EditorView, s: GraphQLSchema) => void) | null>(null); + let view = $state<EditorView | null>(null); + + onMount(() => { + if (!browser) + return; + + let disposed = false; + + (async () => { + const [ + { EditorView: EV, keymap, lineNumbers, highlightActiveLine }, + { EditorState }, + { defaultKeymap, history, historyKeymap }, + { syntaxHighlighting, defaultHighlightStyle, bracketMatching, indentOnInput }, + { closeBrackets, closeBracketsKeymap }, + { graphql, updateSchema }, + { json }, + { buildSchema } + ] = await Promise.all([ + import("@codemirror/view"), + import("@codemirror/state"), + import("@codemirror/commands"), + import("@codemirror/language"), + import("@codemirror/autocomplete"), + import("cm6-graphql"), + import("@codemirror/lang-json"), + import("graphql") + ]); + + if (disposed) + return; + + const themeExt: Extension = theme ?? (await import("@codemirror/theme-one-dark")).oneDark; + + const languageExt = language === "graphql" ? + graphql(schema ? buildSchema(schema) : undefined) : + json(); + + const instance = new EV({ + parent: container, + state: EditorState.create({ + doc: value, + extensions: [ + lineNumbers(), + highlightActiveLine(), + history(), + bracketMatching(), + closeBrackets(), + indentOnInput(), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...historyKeymap]), + languageExt, + themeExt, + EV.editable.of(!readOnly), + EV.updateListener.of((u) => { + if (u.docChanged) + onChange(u.state.doc.toString()); + }) + ] + }) + }); + + view = instance; + updateSchemaFn = updateSchema; + buildSchemaFn = buildSchema; + })(); + + return () => { + disposed = true; + view?.destroy(); + }; + }); + + $effect(() => { + if (!view || !updateSchemaFn || !buildSchemaFn) + return; + + if (language !== "graphql" || !schema) + return; + + try { + updateSchemaFn(view, buildSchemaFn(schema)); + } catch { + // Invalid SDL — silently skip; editor keeps working without schema awareness + } + }); +</script> + +<style lang="scss"> + .editor { + height: 100%; + width: 100%; + + :global(.cm-editor) { + height: 100%; + } + + :global(.cm-scroller) { + font-family: inherit; + } + } +</style> + +<div bind:this={container} class="editor"></div> diff --git a/source/library/components/HeadersEditor.svelte b/source/library/components/HeadersEditor.svelte new file mode 100644 index 0000000..fc3a193 --- /dev/null +++ b/source/library/components/HeadersEditor.svelte @@ -0,0 +1,34 @@ +<script lang="ts"> + import Editor from "./Editor.svelte"; + import type { Extension } from "@codemirror/state"; + + type Props = { + onChange: (value: string) => void; + theme?: Extension; + value: string; + }; + + let { onChange, theme, value }: Props = $props(); +</script> + +<style lang="scss"> + .headers { + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + min-height: 0; + } + + .label { + background-color: var(--graphiql-panel, #252526); + font-size: 0.75rem; + letter-spacing: 0.05em; + padding: 0.25rem 0.75rem; + text-transform: uppercase; + } +</style> + +<div class="headers"> + <div class="label">Headers</div> + <Editor language="json" {onChange} {theme} {value}/> +</div> diff --git a/source/library/components/HistoryPanel.svelte b/source/library/components/HistoryPanel.svelte new file mode 100644 index 0000000..b7f5c4c --- /dev/null +++ b/source/library/components/HistoryPanel.svelte @@ -0,0 +1,187 @@ +<script lang="ts"> + import type { HistoryEntry } from "../state/history.svelte.ts"; + + type Props = { + entries: HistoryEntry[]; + onClear: () => void; + onFavorite: (id: string) => void; + onLoad: (id: string, inNewTab: boolean) => void; + onRemove: (id: string) => void; + }; + + let { entries, onClear, onFavorite, onLoad, onRemove }: Props = $props(); + + const sorted = $derived([...entries].sort((a, b) => { + if (a.favorite !== b.favorite) + return a.favorite ? -1 : 1; + + return b.timestamp - a.timestamp; + })); + + function formatTimestamp(ms: number): string { + const d = new Date(ms); + return d.toLocaleString(); + } + + function onEntryClick(event: MouseEvent, id: string) { + onLoad(id, event.shiftKey); + } + + function onEntryKey(event: KeyboardEvent, id: string) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onLoad(id, event.shiftKey); + } + } +</script> + +<style lang="scss"> + .panel { + background: var(--graphiql-panel, #252526); + border-right: 1px solid var(--graphiql-border, #333); + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + min-height: 0; + overflow: hidden; + } + + .header { + align-items: center; + border-bottom: 1px solid var(--graphiql-border, #333); + display: flex; + justify-content: space-between; + padding: 0.5rem 0.75rem; + } + + .title { + font-size: 0.8125rem; + font-weight: 600; + } + + .clear { + background: none; + border: none; + color: var(--graphiql-muted, #858585); + cursor: pointer; + font-family: inherit; + font-size: 0.75rem; + padding: 0; + + &:hover { + color: var(--graphiql-fg, #d4d4d4); + } + } + + .list { + display: grid; + gap: 0.125rem; + min-height: 0; + overflow-y: auto; + padding: 0.375rem 0; + } + + .entry { + align-items: flex-start; + cursor: pointer; + display: grid; + gap: 0.125rem; + grid-template-columns: auto 1fr auto; + padding: 0.375rem 0.75rem; + + &:hover { + background: var(--graphiql-bg, #1e1e1e); + } + } + + .star { + background: none; + border: none; + color: var(--graphiql-muted, #858585); + cursor: pointer; + font-size: 0.875rem; + padding: 0 0.375rem 0 0; + + &.active { + color: var(--graphiql-accent, #e3b341); + } + } + + .meta { + align-self: center; + min-width: 0; + } + + .entry-title { + font-size: 0.8125rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .entry-time { + color: var(--graphiql-muted, #858585); + font-size: 0.7rem; + } + + .remove { + align-self: center; + background: none; + border: none; + color: var(--graphiql-muted, #858585); + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 0 0.25rem; + + &:hover { + color: var(--graphiql-fg, #d4d4d4); + } + } + + .empty { + color: var(--graphiql-muted, #858585); + font-size: 0.8125rem; + padding: 0.75rem; + } +</style> + +<div class="panel"> + <div class="header"> + <span class="title">History</span> + {#if entries.length > 0} + <button class="clear" onclick={onClear} type="button">Clear</button> + {/if} + </div> + <div class="list"> + {#if sorted.length === 0} + <div class="empty">No history yet.</div> + {:else} + {#each sorted as entry (entry.id)} + <div + aria-label="Load history entry" + class="entry" + onclick={(e) => onEntryClick(e, entry.id)} + onkeydown={(e) => onEntryKey(e, entry.id)} + role="button" + tabindex="0"> + <button + aria-label={entry.favorite ? "Unfavorite" : "Favorite"} + class="star" + class:active={entry.favorite} + onclick={(e) => { e.stopPropagation(); onFavorite(entry.id); }} + type="button">{entry.favorite ? "★" : "☆"}</button> + <div class="meta"> + <div class="entry-title">{entry.title}</div> + <div class="entry-time">{formatTimestamp(entry.timestamp)}</div> + </div> + <button + aria-label="Remove entry" + class="remove" + onclick={(e) => { e.stopPropagation(); onRemove(entry.id); }} + type="button">×</button> + </div> + {/each} + {/if} + </div> +</div> diff --git a/source/library/components/ResultViewer.svelte b/source/library/components/ResultViewer.svelte new file mode 100644 index 0000000..e2c74fe --- /dev/null +++ b/source/library/components/ResultViewer.svelte @@ -0,0 +1,35 @@ +<script lang="ts"> + import Editor from "./Editor.svelte"; + import type { Extension } from "@codemirror/state"; + + type Props = { + theme?: Extension; + value: string; + }; + + let { theme, value }: Props = $props(); + + function noop(_v: string) {} +</script> + +<style lang="scss"> + .result { + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + min-height: 0; + } + + .label { + background: var(--graphiql-panel, #252526); + font-size: 0.75rem; + letter-spacing: 0.05em; + padding: 0.25rem 0.75rem; + text-transform: uppercase; + } +</style> + +<div class="result"> + <div class="label">Response</div> + <Editor language="json" onChange={noop} readOnly {theme} {value}/> +</div> diff --git a/source/library/components/TabBar.svelte b/source/library/components/TabBar.svelte new file mode 100644 index 0000000..d87449d --- /dev/null +++ b/source/library/components/TabBar.svelte @@ -0,0 +1,161 @@ +<script lang="ts"> + import { tick } from "svelte"; + import type { Tab } from "../state/session.svelte.ts"; + + type Props = { + activeId: string; + onAdd: () => void; + onClose: (id: string) => void; + onRename: (id: string, title: string) => void; + onSelect: (id: string) => void; + tabs: Tab[]; + }; + + let { activeId, onAdd, onClose, onRename, onSelect, tabs }: Props = $props(); + + let editingId = $state<string | null>(null); + let draft = $state<string>(""); + let inputEl = $state<HTMLInputElement | null>(null); + + async function startEditing(tab: Tab) { + editingId = tab.id; + draft = tab.title; + await tick(); + inputEl?.select(); + } + + function commit() { + if (editingId === null) return; + onRename(editingId, draft); + editingId = null; + draft = ""; + } + + function cancel() { + editingId = null; + draft = ""; + } + + function onKeydown(event: KeyboardEvent) { + if (event.key === "Enter") { + event.preventDefault(); + commit(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancel(); + } + } + + function handleClose(event: MouseEvent, id: string) { + event.stopPropagation(); + onClose(id); + } +</script> + +<style lang="scss"> + .tabbar { + align-items: stretch; + background: var(--graphiql-panel, #252526); + border-bottom: 1px solid var(--graphiql-border, #333); + display: flex; + font-size: 0.8125rem; + min-height: 2rem; + overflow-x: auto; + } + + .tab { + align-items: center; + background: transparent; + border: none; + border-right: 1px solid var(--graphiql-border, #333); + color: var(--graphiql-muted, #858585); + cursor: pointer; + display: flex; + gap: 0.5rem; + padding: 0 0.75rem; + + &.active { + background: var(--graphiql-bg, #1e1e1e); + color: var(--graphiql-fg, #d4d4d4); + } + + &:hover:not(.active) { + color: var(--graphiql-fg, #d4d4d4); + } + } + + .title { + white-space: nowrap; + } + + .edit { + background: var(--graphiql-bg, #1e1e1e); + border: 1px solid var(--graphiql-accent, #0e639c); + border-radius: 2px; + color: var(--graphiql-fg, #d4d4d4); + font-family: inherit; + font-size: inherit; + min-width: 6rem; + outline: none; + padding: 0.125rem 0.25rem; + } + + .close { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 1rem; + line-height: 1; + opacity: 0.6; + padding: 0; + + &:hover { + opacity: 1; + } + } + + .add { + background: none; + border: none; + color: var(--graphiql-muted, #858585); + cursor: pointer; + font-size: 1rem; + padding: 0 0.75rem; + + &:hover { + color: var(--graphiql-fg, #d4d4d4); + } + } +</style> + +<div class="tabbar"> + {#each tabs as tab (tab.id)} + <button + class="tab" + class:active={tab.id === activeId} + ondblclick={() => startEditing(tab)} + onclick={() => onSelect(tab.id)} + > + {#if editingId === tab.id} + <input + bind:this={inputEl} + bind:value={draft} + class="edit" + onblur={commit} + onclick={(e) => e.stopPropagation()} + onkeydown={onKeydown} + type="text" + /> + {:else} + <span class="title">{tab.title}</span> + {/if} + <button + aria-label="Close tab" + class="close" + onclick={(e) => handleClose(e, tab.id)} + >×</button> + </button> + {/each} + <button aria-label="New tab" class="add" onclick={onAdd}>+</button> +</div> diff --git a/source/library/components/Toolbar.svelte b/source/library/components/Toolbar.svelte new file mode 100644 index 0000000..a17191c --- /dev/null +++ b/source/library/components/Toolbar.svelte @@ -0,0 +1,150 @@ +<script lang="ts"> + import type { OperationInfo } from "../graphql/operations.ts"; + + type Props = { + disabled: boolean; + docsAvailable?: boolean; + docsOpen?: boolean; + historyOpen?: boolean; + onRun: () => void; + onSelectOperation?: (name: string | null) => void; + onToggleDocs?: () => void; + onToggleHistory?: () => void; + operationName?: string | null; + operations?: OperationInfo[]; + running: boolean; + schemaLoading: boolean; + }; + + let { + disabled, + docsAvailable = false, + docsOpen = false, + historyOpen = false, + onRun, + onSelectOperation, + onToggleDocs, + onToggleHistory, + operationName = null, + operations = [], + running, + schemaLoading + }: Props = $props(); + + const namedOperations = $derived(operations.filter((o) => o.name !== null)); + + function onPick(event: Event) { + const value = (event.currentTarget as HTMLSelectElement).value; + onSelectOperation?.(value || null); + } +</script> + +<style lang="scss"> + .toolbar { + align-items: center; + background: var(--graphiql-panel, #252526); + border-bottom: 1px solid var(--graphiql-border, #333); + display: flex; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + } + + .run { + background: var(--graphiql-accent, #0e639c); + border: none; + border-radius: 3px; + color: #fff; + cursor: pointer; + font-size: 0.875rem; + padding: 0.375rem 1rem; + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + } + + .hint { + color: var(--graphiql-muted, #858585); + font-size: 0.75rem; + } + + .picker { + background: var(--graphiql-bg, #1e1e1e); + border: 1px solid var(--graphiql-border, #333); + border-radius: 3px; + color: var(--graphiql-fg, #d4d4d4); + font-family: inherit; + font-size: 0.8125rem; + padding: 0.25rem 0.5rem; + } + + .spacer { + flex: 1; + } + + .toggle { + background: none; + border: 1px solid var(--graphiql-border, #333); + border-radius: 3px; + color: var(--graphiql-muted, #858585); + cursor: pointer; + font-family: inherit; + font-size: 0.75rem; + padding: 0.25rem 0.625rem; + + &.active { + background: var(--graphiql-bg, #1e1e1e); + color: var(--graphiql-fg, #d4d4d4); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } + + &:hover:not(:disabled) { + color: var(--graphiql-fg, #d4d4d4); + } + } +</style> + +<div class="toolbar"> + <button class="run" {disabled} onclick={onRun}> + {running ? "Running…" : "Run"} + </button> + {#if namedOperations.length > 1} + <select + aria-label="Operation" + class="picker" + onchange={onPick} + value={operationName ?? ""}> + <option value="">Select operation…</option> + {#each namedOperations as op} + <option value={op.name}>{op.type} {op.name}</option> + {/each} + </select> + {/if} + <span class="hint">⌘/Ctrl + Enter</span> + {#if schemaLoading} + <span class="hint">Loading schema…</span> + {/if} + <span class="spacer"></span> + {#if onToggleHistory} + <button + aria-pressed={historyOpen} + class="toggle" + class:active={historyOpen} + onclick={onToggleHistory} + type="button">History</button> + {/if} + {#if onToggleDocs} + <button + aria-pressed={docsOpen} + class="toggle" + class:active={docsOpen} + disabled={!docsAvailable} + onclick={onToggleDocs} + type="button">Docs</button> + {/if} +</div> |