diff --git a/demos/__tests__/contract-templates-locate.spec.ts b/demos/__tests__/contract-templates-locate.spec.ts new file mode 100644 index 0000000000..40d9e76016 --- /dev/null +++ b/demos/__tests__/contract-templates-locate.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '@playwright/test'; + +/** + * Demo smoke for the contract-templates "Locate" affordance (dogfoods + * `ui.contentControls.scrollIntoView`): clicking a lower clause's Locate + * button scrolls that control's painted element into view. + * + * The shared suite runs once per DEMO, so this skips for every other demo. + */ + +// A short viewport so the bottom clause starts below the fold. +test.use({ viewport: { width: 1100, height: 520 } }); + +test('clicking a lower clause Locate scrolls its content control into view', async ({ page }) => { + test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); + + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(e.message)); + page.on('console', (m) => { + if (m.type() === 'error') errors.push(m.text()); + }); + await page.route('**/ingest.superdoc.dev/**', (r) => + r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }), + ); + + await page.goto('/'); + await expect(page.locator('body')).toBeVisible({ timeout: 30_000 }); + + // Wait until SuperDoc has imported the fixture and the UI handle sees controls. + await page.waitForFunction( + () => { + const ui = (window as unknown as { __demo?: { state?: { ui?: unknown } } }).__demo?.state?.ui as + | { contentControls: { getSnapshot(): { items: unknown[] } } } + | undefined; + return !!ui && ui.contentControls.getSnapshot().items.length > 0; + }, + null, + { timeout: 30_000 }, + ); + + // Locate buttons on clause cards live in the (initially hidden) clauses panel. + await page.click('.tab[data-tab="clauses"]'); + await page.waitForSelector('[data-locate-clause]'); + + // Resolve the bottom-most block clause: its painted id (data-sdt-id) and its + // sectionId (= the Locate button's data-locate-clause). + const target = await page.evaluate(() => { + const ui = (window as unknown as { __demo: { state: { ui: { contentControls: { getSnapshot(): { items: Array<{ id: string; kind: string; properties?: { tag?: string } }> } } } } } }).__demo.state.ui; + const items = ui.contentControls.getSnapshot().items; + const blocks = items.filter((i) => i.kind === 'block'); + const last = blocks[blocks.length - 1]; + let sectionId: string | null = null; + try { + sectionId = JSON.parse(last?.properties?.tag ?? '{}').sectionId ?? null; + } catch { + sectionId = null; + } + return { id: last?.id ?? null, sectionId }; + }); + expect(target.id).toBeTruthy(); + expect(target.sectionId).toBeTruthy(); + + const inViewport = () => + page.evaluate((id) => { + const el = document.querySelector(`[data-sdt-id="${id}"]`); + if (!el) return false; + const r = el.getBoundingClientRect(); + return r.top >= 0 && r.top <= window.innerHeight; + }, target.id); + + // The bottom clause starts off-screen. + expect(await inViewport()).toBe(false); + + // Click its Locate button; the document should scroll it into view. + await page.click(`[data-locate-clause="${target.sectionId}"]`); + await expect.poll(inViewport, { timeout: 5_000 }).toBe(true); + + expect(errors).toEqual([]); +}); diff --git a/demos/contract-templates/src/main.ts b/demos/contract-templates/src/main.ts index 83f34f54ec..0a888af144 100644 --- a/demos/contract-templates/src/main.ts +++ b/demos/contract-templates/src/main.ts @@ -358,6 +358,24 @@ async function exportDocument(mode: 'raw' | 'clean'): Promise { // Rendering // --------------------------------------------------------------------------- +/** + * Scroll the first control carrying `tag` into view. Dogfoods the shipped + * public API: resolve the control id by tag (`selectByTag`), then + * `ui.contentControls.scrollIntoView`. Scroll-only - it does not move the + * cursor into the control (focus/activate is a separate concern). + */ +function locateByTag(tag: string): void { + const ui = state.ui; + const editor = state.editor; + if (!ui || !editor?.doc) return; + const { items } = editor.doc.contentControls.selectByTag({ tag }); + const first = items[0]; + if (!first) return; + // `target.nodeId` is the SDT node id (= the painted `data-sdt-id`), which is + // what scrollIntoView matches on. + void ui.contentControls.scrollIntoView({ id: first.target.nodeId, block: 'center' }); +} + function renderPanels(): void { renderFieldsPanel(); renderClausesPanel(); @@ -366,13 +384,23 @@ function renderPanels(): void { function renderFieldsPanel(): void { fieldsPanelEl.innerHTML = ''; for (const field of FIELDS) { - const row = document.createElement('label'); + // A
wrapper (not
+ `; fieldsPanelEl.appendChild(row); + row.querySelector('.locate')?.addEventListener('click', () => { + locateByTag(fieldTag(field.key)); + }); const input = row.querySelector('input'); if (!input) continue; // Reactive: each keystroke debounces ~250ms and fans the value to every @@ -404,7 +432,10 @@ function renderClausesPanel(): void { card.innerHTML = `

${escapeHtml(clause.label)}

- Update available +
+ Update available + +

${escapeHtml(upgrade.summary)}

Document ${escapeHtml(inDoc)} \u00b7 Library ${escapeHtml(upgrade.version)}

@@ -435,12 +466,18 @@ function renderClausesPanel(): void { card.innerHTML = `

${escapeHtml(clause.label)}

- Current +
+ Current + +

Document ${escapeHtml(inDoc)}

`; } + card.querySelector('.locate')?.addEventListener('click', () => { + locateByTag(clauseTag(clause.id, inDoc)); + }); clausesPanelEl.appendChild(card); } } diff --git a/demos/contract-templates/src/style.css b/demos/contract-templates/src/style.css index e867ad1785..2945ecabe5 100644 --- a/demos/contract-templates/src/style.css +++ b/demos/contract-templates/src/style.css @@ -97,7 +97,10 @@ button { cursor: pointer; } .row { display: block; margin-bottom: 10px; } .row-label { - display: block; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; font-size: var(--sd-font-size-200, 12px); color: var(--demo-text-muted); margin-bottom: 4px; @@ -133,6 +136,23 @@ input:focus { } .btn:disabled { color: var(--demo-text-muted); cursor: not-allowed; opacity: 0.55; } +/* "Locate" — scroll a field/clause control into view (ui.contentControls.scrollIntoView). */ +.clause-actions { display: flex; align-items: center; gap: 8px; } +.locate { + font: inherit; + font-size: var(--sd-font-size-100, 11px); + line-height: 1; + padding: 3px 8px; + border: 1px solid var(--demo-border); + border-radius: var(--sd-radius-50, 4px); + background: transparent; + color: var(--demo-text-muted); + cursor: pointer; + white-space: nowrap; +} +.locate:hover { color: var(--demo-text); border-color: var(--demo-accent); } +.locate:focus-visible { outline: 1px solid var(--demo-accent); outline-offset: 1px; } + #fields-panel .btn { width: 100%; margin-top: 12px; } /* -----------------------------------------------------------------------