Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions demos/__tests__/contract-templates-locate.spec.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
47 changes: 42 additions & 5 deletions demos/contract-templates/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,24 @@ async function exportDocument(mode: 'raw' | 'clean'): Promise<void> {
// 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();
Expand All @@ -366,13 +384,23 @@ function renderPanels(): void {
function renderFieldsPanel(): void {
fieldsPanelEl.innerHTML = '';
for (const field of FIELDS) {
const row = document.createElement('label');
// A <div> wrapper (not <label>): a <label> may not contain interactive
// content, so the Locate <button> must be a sibling of the input, with a
// real <label for> tying the field name to the input.
const row = document.createElement('div');
row.className = 'row';
const inputId = `field-${field.key}`;
row.innerHTML = `
<span class="row-label">${escapeHtml(field.label)}</span>
<input data-field="${field.key}" value="${escapeAttr(state.values[field.key] ?? '')}" />
<div class="row-label">
<label class="row-label-text" for="${inputId}">${escapeHtml(field.label)}</label>
<button class="locate" type="button" data-locate-field="${escapeAttr(field.key)}" aria-label="Locate ${escapeAttr(field.label)} in the document" title="Scroll to this field">Locate</button>
</div>
<input id="${inputId}" data-field="${field.key}" value="${escapeAttr(state.values[field.key] ?? '')}" />
`;
fieldsPanelEl.appendChild(row);
row.querySelector<HTMLButtonElement>('.locate')?.addEventListener('click', () => {
locateByTag(fieldTag(field.key));
});
const input = row.querySelector<HTMLInputElement>('input');
if (!input) continue;
// Reactive: each keystroke debounces ~250ms and fans the value to every
Expand Down Expand Up @@ -404,7 +432,10 @@ function renderClausesPanel(): void {
card.innerHTML = `
<header class="clause-header">
<h3 class="clause-label">${escapeHtml(clause.label)}</h3>
<span class="clause-status">Update available</span>
<div class="clause-actions">
<span class="clause-status">Update available</span>
<button class="locate" type="button" data-locate-clause="${escapeAttr(clause.id)}" aria-label="Locate ${escapeAttr(clause.label)} in the document" title="Scroll to this clause">Locate</button>
</div>
</header>
<p class="clause-summary">${escapeHtml(upgrade.summary)}</p>
<p class="clause-meta">Document ${escapeHtml(inDoc)} \u00b7 Library ${escapeHtml(upgrade.version)}</p>
Expand Down Expand Up @@ -435,12 +466,18 @@ function renderClausesPanel(): void {
card.innerHTML = `
<header class="clause-header">
<h3 class="clause-label">${escapeHtml(clause.label)}</h3>
<span class="clause-status muted">Current</span>
<div class="clause-actions">
<span class="clause-status muted">Current</span>
<button class="locate" type="button" data-locate-clause="${escapeAttr(clause.id)}" aria-label="Locate ${escapeAttr(clause.label)} in the document" title="Scroll to this clause">Locate</button>
</div>
</header>
<p class="clause-meta">Document ${escapeHtml(inDoc)}</p>
`;
}

card.querySelector<HTMLButtonElement>('.locate')?.addEventListener('click', () => {
locateByTag(clauseTag(clause.id, inDoc));
});
clausesPanelEl.appendChild(card);
}
}
Expand Down
22 changes: 21 additions & 1 deletion demos/contract-templates/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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; }

/* -----------------------------------------------------------------------
Expand Down
Loading