mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Onboarding: paste-a-spec door, workspace-import and learn links
This commit is contained in:
@@ -14,13 +14,14 @@
|
||||
* independent of `LivePreview`'s single-host render serialization.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { VisualizationSpec } from 'vega-embed';
|
||||
import { CHART_EXAMPLES, exampleSpecText, type ChartExample } from '@core/examples';
|
||||
import { createSnippet as createSnippetRecord } from '@core/snippet';
|
||||
import { createSnippet as createSnippetRecord, deriveSnippetName } from '@core/snippet';
|
||||
import { chartConfigFor } from '@core/vega-themes';
|
||||
import { openModal } from '../modals/ModalCoordinator';
|
||||
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
||||
import { importWorkspace } from '../services/transfer';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { usePanesStore } from '../stores/PanesStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
@@ -91,6 +92,23 @@ export function Onboarding() {
|
||||
const addSnippets = useSnippetStore((s) => s.addSnippets);
|
||||
const applyOnboardingSplit = usePanesStore((s) => s.applyOnboardingSplit);
|
||||
|
||||
// The paste door (spec §02): a disclosure (WAI-ARIA APG disclosure pattern —
|
||||
// button + aria-expanded/aria-controls, no focus trap), not a modal: the canvas
|
||||
// has the whole workspace to itself, so revealing the paste surface in place
|
||||
// costs no context. The panel stays mounted (`hidden`) so a draft paste
|
||||
// survives a collapse.
|
||||
const [pasteOpen, setPasteOpen] = useState(false);
|
||||
const [pasteText, setPasteText] = useState('');
|
||||
const pasteTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const pasteAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// The revealed panel exists only for immediate input, so focus follows the
|
||||
// expand; Cancel returns it to the trigger (NN/g #3 — a clearly marked exit).
|
||||
useEffect(() => {
|
||||
if (pasteOpen) pasteAreaRef.current?.focus();
|
||||
}, [pasteOpen]);
|
||||
|
||||
// Leaving onboarding lays the workspace out at the default 25·25·50 split, so
|
||||
// the first chart opens with a generous preview (spec §02). The canvas fills
|
||||
// the window here, so its width is a good proxy for the panes container.
|
||||
@@ -116,6 +134,36 @@ export function Onboarding() {
|
||||
openModal('chartBuilder');
|
||||
};
|
||||
|
||||
// Pasted text is accepted as-is: the editor is the product's validator, and an
|
||||
// almost-right spec opening with live schema errors is the feature working.
|
||||
// The name derives from the spec (title → "Mark chart of y by x"), with
|
||||
// `nameSource: 'auto'` so it keeps tracking the spec until the user renames.
|
||||
const handlePasteAdd = () => {
|
||||
const text = pasteText.trim();
|
||||
if (!text) return;
|
||||
createSnippet({
|
||||
name: deriveSnippetName(text) ?? 'Pasted spec',
|
||||
nameSource: 'auto',
|
||||
spec: text,
|
||||
});
|
||||
layoutWorkspace();
|
||||
};
|
||||
const closePaste = () => {
|
||||
setPasteOpen(false);
|
||||
pasteTriggerRef.current?.focus();
|
||||
};
|
||||
|
||||
// Same hidden-picker pattern as the header's Import (spec §08 — the browser
|
||||
// file dialog is the only chrome); reset so re-picking the same file re-fires.
|
||||
// TODO: third hidden-file-picker site (App.tsx header, TypeControls.tsx) — past
|
||||
// the shared-piece threshold; eng-council proposed a useFilePicker/HiddenFileInput
|
||||
// shape. Consult before building (new hook/component kind).
|
||||
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (file) void importWorkspace(file);
|
||||
};
|
||||
|
||||
const handleAddAll = () => {
|
||||
// Stagger the timestamps so the first example (the bar chart) is the newest:
|
||||
// it then sorts to the top of the library and `addSnippets` makes it active
|
||||
@@ -148,8 +196,74 @@ export function Onboarding() {
|
||||
<Button size="lg" onClick={handleBuild}>
|
||||
<Icon name="chart" /> Build a chart from your data
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
ref={pasteTriggerRef}
|
||||
aria-expanded={pasteOpen}
|
||||
aria-controls="onboarding-paste-panel"
|
||||
onClick={() => setPasteOpen((open) => !open)}
|
||||
>
|
||||
<Icon name="import" /> Paste a spec you already have
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div id="onboarding-paste-panel" className={styles.pastePanel} hidden={!pasteOpen}>
|
||||
{/* Visible label above the field (GOV.UK textarea — placeholder text is
|
||||
not a substitute for a label). */}
|
||||
<label className={styles.pasteLabel} htmlFor="onboarding-paste-input">
|
||||
Paste a Vega-Lite spec
|
||||
</label>
|
||||
<p className={styles.pasteHint} id="onboarding-paste-hint">
|
||||
Vega-Lite JSON from anywhere — a notebook, the Vega editor, an AI chat. It becomes your
|
||||
first snippet and opens in the editor, live errors and all.
|
||||
</p>
|
||||
<textarea
|
||||
ref={pasteAreaRef}
|
||||
id="onboarding-paste-input"
|
||||
className={styles.pasteInput}
|
||||
aria-describedby="onboarding-paste-hint"
|
||||
rows={8}
|
||||
spellCheck={false}
|
||||
value={pasteText}
|
||||
onChange={(e) => setPasteText(e.target.value)}
|
||||
/>
|
||||
<div className={styles.pasteActions}>
|
||||
<Button onClick={handlePasteAdd} disabled={pasteText.trim() === ''}>
|
||||
Add to library
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={closePaste}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secondary paths as quiet links below the doors (Carbon empty-states —
|
||||
secondary calls to action are links, not more buttons). */}
|
||||
<p className={styles.altPaths}>
|
||||
Restoring from an export?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
>
|
||||
Import your workspace
|
||||
</button>
|
||||
<span aria-hidden="true"> · </span>
|
||||
New to Vega-Lite?{' '}
|
||||
<a className={styles.linkButton} href="/learn/" target="_blank" rel="noopener noreferrer">
|
||||
Read the deep dives
|
||||
</a>
|
||||
</p>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className={styles.hiddenInput}
|
||||
onChange={handleImportFile}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
<div className={styles.galleryHead}>
|
||||
<h3 className={styles.galleryTitle}>Or start from an example</h3>
|
||||
<Button className={styles.addAll} onClick={handleAddAll}>
|
||||
|
||||
Reference in New Issue
Block a user