Skip to content

Commit 3fbfa3f

Browse files
authored
Merge pull request #3564 from superdoc-dev/caio/contract-templates-locate
demo(contract-templates): Locate button to scroll to a field or clause (SD-3314)
2 parents 4d85c51 + 6234b10 commit 3fbfa3f

3 files changed

Lines changed: 142 additions & 6 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
/**
4+
* Demo smoke for the contract-templates "Locate" affordance (dogfoods
5+
* `ui.contentControls.scrollIntoView`): clicking a lower clause's Locate
6+
* button scrolls that control's painted element into view.
7+
*
8+
* The shared suite runs once per DEMO, so this skips for every other demo.
9+
*/
10+
11+
// A short viewport so the bottom clause starts below the fold.
12+
test.use({ viewport: { width: 1100, height: 520 } });
13+
14+
test('clicking a lower clause Locate scrolls its content control into view', async ({ page }) => {
15+
test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only');
16+
17+
const errors: string[] = [];
18+
page.on('pageerror', (e) => errors.push(e.message));
19+
page.on('console', (m) => {
20+
if (m.type() === 'error') errors.push(m.text());
21+
});
22+
await page.route('**/ingest.superdoc.dev/**', (r) =>
23+
r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }),
24+
);
25+
26+
await page.goto('/');
27+
await expect(page.locator('body')).toBeVisible({ timeout: 30_000 });
28+
29+
// Wait until SuperDoc has imported the fixture and the UI handle sees controls.
30+
await page.waitForFunction(
31+
() => {
32+
const ui = (window as unknown as { __demo?: { state?: { ui?: unknown } } }).__demo?.state?.ui as
33+
| { contentControls: { getSnapshot(): { items: unknown[] } } }
34+
| undefined;
35+
return !!ui && ui.contentControls.getSnapshot().items.length > 0;
36+
},
37+
null,
38+
{ timeout: 30_000 },
39+
);
40+
41+
// Locate buttons on clause cards live in the (initially hidden) clauses panel.
42+
await page.click('.tab[data-tab="clauses"]');
43+
await page.waitForSelector('[data-locate-clause]');
44+
45+
// Resolve the bottom-most block clause: its painted id (data-sdt-id) and its
46+
// sectionId (= the Locate button's data-locate-clause).
47+
const target = await page.evaluate(() => {
48+
const ui = (window as unknown as { __demo: { state: { ui: { contentControls: { getSnapshot(): { items: Array<{ id: string; kind: string; properties?: { tag?: string } }> } } } } } }).__demo.state.ui;
49+
const items = ui.contentControls.getSnapshot().items;
50+
const blocks = items.filter((i) => i.kind === 'block');
51+
const last = blocks[blocks.length - 1];
52+
let sectionId: string | null = null;
53+
try {
54+
sectionId = JSON.parse(last?.properties?.tag ?? '{}').sectionId ?? null;
55+
} catch {
56+
sectionId = null;
57+
}
58+
return { id: last?.id ?? null, sectionId };
59+
});
60+
expect(target.id).toBeTruthy();
61+
expect(target.sectionId).toBeTruthy();
62+
63+
const inViewport = () =>
64+
page.evaluate((id) => {
65+
const el = document.querySelector(`[data-sdt-id="${id}"]`);
66+
if (!el) return false;
67+
const r = el.getBoundingClientRect();
68+
return r.top >= 0 && r.top <= window.innerHeight;
69+
}, target.id);
70+
71+
// The bottom clause starts off-screen.
72+
expect(await inViewport()).toBe(false);
73+
74+
// Click its Locate button; the document should scroll it into view.
75+
await page.click(`[data-locate-clause="${target.sectionId}"]`);
76+
await expect.poll(inViewport, { timeout: 5_000 }).toBe(true);
77+
78+
expect(errors).toEqual([]);
79+
});

demos/contract-templates/src/main.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,24 @@ async function exportDocument(mode: 'raw' | 'clean'): Promise<void> {
358358
// Rendering
359359
// ---------------------------------------------------------------------------
360360

361+
/**
362+
* Scroll the first control carrying `tag` into view. Dogfoods the shipped
363+
* public API: resolve the control id by tag (`selectByTag`), then
364+
* `ui.contentControls.scrollIntoView`. Scroll-only - it does not move the
365+
* cursor into the control (focus/activate is a separate concern).
366+
*/
367+
function locateByTag(tag: string): void {
368+
const ui = state.ui;
369+
const editor = state.editor;
370+
if (!ui || !editor?.doc) return;
371+
const { items } = editor.doc.contentControls.selectByTag({ tag });
372+
const first = items[0];
373+
if (!first) return;
374+
// `target.nodeId` is the SDT node id (= the painted `data-sdt-id`), which is
375+
// what scrollIntoView matches on.
376+
void ui.contentControls.scrollIntoView({ id: first.target.nodeId, block: 'center' });
377+
}
378+
361379
function renderPanels(): void {
362380
renderFieldsPanel();
363381
renderClausesPanel();
@@ -366,13 +384,23 @@ function renderPanels(): void {
366384
function renderFieldsPanel(): void {
367385
fieldsPanelEl.innerHTML = '';
368386
for (const field of FIELDS) {
369-
const row = document.createElement('label');
387+
// A <div> wrapper (not <label>): a <label> may not contain interactive
388+
// content, so the Locate <button> must be a sibling of the input, with a
389+
// real <label for> tying the field name to the input.
390+
const row = document.createElement('div');
370391
row.className = 'row';
392+
const inputId = `field-${field.key}`;
371393
row.innerHTML = `
372-
<span class="row-label">${escapeHtml(field.label)}</span>
373-
<input data-field="${field.key}" value="${escapeAttr(state.values[field.key] ?? '')}" />
394+
<div class="row-label">
395+
<label class="row-label-text" for="${inputId}">${escapeHtml(field.label)}</label>
396+
<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>
397+
</div>
398+
<input id="${inputId}" data-field="${field.key}" value="${escapeAttr(state.values[field.key] ?? '')}" />
374399
`;
375400
fieldsPanelEl.appendChild(row);
401+
row.querySelector<HTMLButtonElement>('.locate')?.addEventListener('click', () => {
402+
locateByTag(fieldTag(field.key));
403+
});
376404
const input = row.querySelector<HTMLInputElement>('input');
377405
if (!input) continue;
378406
// Reactive: each keystroke debounces ~250ms and fans the value to every
@@ -404,7 +432,10 @@ function renderClausesPanel(): void {
404432
card.innerHTML = `
405433
<header class="clause-header">
406434
<h3 class="clause-label">${escapeHtml(clause.label)}</h3>
407-
<span class="clause-status">Update available</span>
435+
<div class="clause-actions">
436+
<span class="clause-status">Update available</span>
437+
<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>
438+
</div>
408439
</header>
409440
<p class="clause-summary">${escapeHtml(upgrade.summary)}</p>
410441
<p class="clause-meta">Document ${escapeHtml(inDoc)} \u00b7 Library ${escapeHtml(upgrade.version)}</p>
@@ -435,12 +466,18 @@ function renderClausesPanel(): void {
435466
card.innerHTML = `
436467
<header class="clause-header">
437468
<h3 class="clause-label">${escapeHtml(clause.label)}</h3>
438-
<span class="clause-status muted">Current</span>
469+
<div class="clause-actions">
470+
<span class="clause-status muted">Current</span>
471+
<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>
472+
</div>
439473
</header>
440474
<p class="clause-meta">Document ${escapeHtml(inDoc)}</p>
441475
`;
442476
}
443477

478+
card.querySelector<HTMLButtonElement>('.locate')?.addEventListener('click', () => {
479+
locateByTag(clauseTag(clause.id, inDoc));
480+
});
444481
clausesPanelEl.appendChild(card);
445482
}
446483
}

demos/contract-templates/src/style.css

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,10 @@ button { cursor: pointer; }
9797

9898
.row { display: block; margin-bottom: 10px; }
9999
.row-label {
100-
display: block;
100+
display: flex;
101+
align-items: center;
102+
justify-content: space-between;
103+
gap: 8px;
101104
font-size: var(--sd-font-size-200, 12px);
102105
color: var(--demo-text-muted);
103106
margin-bottom: 4px;
@@ -133,6 +136,23 @@ input:focus {
133136
}
134137
.btn:disabled { color: var(--demo-text-muted); cursor: not-allowed; opacity: 0.55; }
135138

139+
/* "Locate" — scroll a field/clause control into view (ui.contentControls.scrollIntoView). */
140+
.clause-actions { display: flex; align-items: center; gap: 8px; }
141+
.locate {
142+
font: inherit;
143+
font-size: var(--sd-font-size-100, 11px);
144+
line-height: 1;
145+
padding: 3px 8px;
146+
border: 1px solid var(--demo-border);
147+
border-radius: var(--sd-radius-50, 4px);
148+
background: transparent;
149+
color: var(--demo-text-muted);
150+
cursor: pointer;
151+
white-space: nowrap;
152+
}
153+
.locate:hover { color: var(--demo-text); border-color: var(--demo-accent); }
154+
.locate:focus-visible { outline: 1px solid var(--demo-accent); outline-offset: 1px; }
155+
136156
#fields-panel .btn { width: 100%; margin-top: 12px; }
137157

138158
/* -----------------------------------------------------------------------

0 commit comments

Comments
 (0)