diff --git a/e2e/TldUrl.e2e-spec.ts b/e2e/TldUrl.e2e-spec.ts new file mode 100644 index 00000000..27b3b72e --- /dev/null +++ b/e2e/TldUrl.e2e-spec.ts @@ -0,0 +1,133 @@ +import type { Page, Request, Route } from '@playwright/test'; +import { test, expect } from './global-setup'; + +const resultPath = '/en/result/226f6d4f44ae3f80/'; + +const isTldUrlRequest = (request: Request) => + request.postDataJSON()?.method === 'get_tld_url'; + +async function mockTldUrl(page: Page, handle: (route: Route) => Promise) { + await page.route(/\/api$/, async (route) => { + if (isTldUrlRequest(route.request())) { + await handle(route); + } else { + await route.fallback(); + } + }); +} + +async function fulfillRpc( + route: Route, + payload: + | { result: { tld?: string; url?: string } } + | { error: { code: number; message: string } }, +) { + await route.fulfill({ + json: { jsonrpc: '2.0', id: route.request().postDataJSON().id, ...payload }, + }); +} + +async function expectResults(page: Page) { + await expect( + page.getByRole('heading', { name: 'Test result for results.afNiC.Fr' }), + ).toBeVisible(); + await expect(page.locator('.zm-result')).toBeVisible(); +} + +test.describe('TLD URL on test results', () => { + for (const url of ['https://www.afnic.fr/', 'http://www.afnic.fr/']) { + test(`displays and copies ${url}`, async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await mockTldUrl(page, (route) => + fulfillRpc(route, { result: { tld: 'fr', url } }), + ); + const requestPromise = page.waitForRequest(isTldUrlRequest); + + await page.goto(resultPath); + const request = await requestPromise; + expect(request.postDataJSON().params).toEqual({ + domain: 'results.afNiC.Fr', + }); + await expectResults(page); + + const button = page.locator('#zmTLDURLButton'); + const dialog = page.locator('#copyTLDURLDialog'); + await expect(button).toBeVisible(); + await expect(button).toHaveText('.fr Info'); + await expect(dialog).toBeHidden(); + await button.click(); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText('Placeholder information about the TLD URL.')).toBeVisible(); + await expect(dialog.locator('input[name="url"]')).toHaveValue(url); + const openLink = dialog.getByRole('link', { name: 'Open TLD URL in a new tab' }); + await expect(openLink).toHaveAttribute('href', url); + await expect(openLink).toHaveAttribute('target', '_blank'); + await expect(openLink).toHaveAttribute('rel', 'noopener noreferrer'); + const copyButton = dialog.getByRole('button'); + await copyButton.click(); + await expect(copyButton).toHaveAttribute('aria-label', 'Copied'); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(url); + }); + } + + const hiddenButtonCases = [ + { + description: 'the URL is omitted', + response: { result: {} }, + }, + { + description: 'the URL is empty', + response: { result: { url: '' } }, + }, + { + description: 'the backend returns an RPC error', + response: { error: { code: -32601, message: 'Method not found' } }, + }, + { + description: 'the request fails', + response: null, + }, + ]; + + for (const { description, response } of hiddenButtonCases) { + test(`hides the button when ${description}`, async ({ page }) => { + const errors: Error[] = []; + page.on('pageerror', (error) => errors.push(error)); + await mockTldUrl(page, (route) => + response ? fulfillRpc(route, response) : route.abort('failed'), + ); + const settled = response + ? page.waitForEvent('requestfinished', isTldUrlRequest) + : page.waitForEvent('requestfailed', isTldUrlRequest); + + await page.goto(resultPath); + await settled; + await expectResults(page); + await expect(page.locator('#zmTLDURLButton')).toBeHidden(); + expect(errors).toEqual([]); + }); + } + + test('shows results while waiting for the TLD URL', async ({ page }) => { + let releaseRequest!: () => void; + const pending = new Promise((resolve) => { + releaseRequest = resolve; + }); + await mockTldUrl(page, async (route) => { + await pending; + await fulfillRpc(route, { result: { url: 'https://www.afnic.fr/' } }); + }); + const requestPromise = page.waitForRequest(isTldUrlRequest); + + try { + await page.goto(resultPath, { waitUntil: 'domcontentloaded' }); + await requestPromise; + await expectResults(page); + await expect(page.locator('#zmTLDURLButton')).toBeHidden(); + } finally { + releaseRequest(); + } + + await expect(page.locator('#zmTLDURLButton')).toBeVisible(); + }); +}); diff --git a/e2e/interceptors/mock.interceptor.ts b/e2e/interceptors/mock.interceptor.ts index 1d69fa1d..5aa9389e 100644 --- a/e2e/interceptors/mock.interceptor.ts +++ b/e2e/interceptors/mock.interceptor.ts @@ -986,5 +986,12 @@ export function findMockResponse(url: string, method: string, body: any): any | return element.json; } } + + // Default for get_tld_url: no URL, so the TLD link stays hidden unless a + // table entry or a test-specific route provides one. + if (method === 'POST' && body?.method === 'get_tld_url') { + return { jsonrpc: '2.0', id: body.id, result: {} }; + } + return null; } diff --git a/messages/da.json b/messages/da.json index ead1ac67..60b243dd 100644 --- a/messages/da.json +++ b/messages/da.json @@ -77,6 +77,8 @@ "filterSeverityLevels": "Filtrer sværhedsgrader", "profile": "Profil", "tldURL": "TLD-URL", + "tldURLInfo": "Placeholder information about the TLD URL.", + "openTldURLInNewTab": "Åbn TLD-URL i en ny fane", "exportResult": "Eksportér", "exportJson": "JSON", "exportHtml": "HTML", diff --git a/messages/en.json b/messages/en.json index d26c003a..033980e5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -77,6 +77,8 @@ "filterSeverityLevels": "Filter severity levels", "profile": "Profile", "tldURL": "TLD URL", + "tldURLInfo": "Placeholder information about the TLD URL.", + "openTldURLInNewTab": "Open TLD URL in a new tab", "exportResult": "Export", "exportJson": "JSON", "exportHtml": "HTML", diff --git a/messages/es.json b/messages/es.json index c21058e1..3fab4b66 100644 --- a/messages/es.json +++ b/messages/es.json @@ -77,6 +77,8 @@ "filterSeverityLevels": "Filtrar niveles de gravedad", "profile": "Perfil", "tldURL": "URL del TLD", + "tldURLInfo": "Placeholder information about the TLD URL.", + "openTldURLInNewTab": "Abrir la URL del TLD en una pestaña nueva", "exportResult": "Exportar", "exportJson": "JSON", "exportHtml": "HTML", diff --git a/messages/fi.json b/messages/fi.json index c8c3ac0a..059eeefa 100644 --- a/messages/fi.json +++ b/messages/fi.json @@ -77,6 +77,8 @@ "filterSeverityLevels": "Suodata vakavuustasot", "profile": "Profiili", "tldURL": "TLD-URL", + "tldURLInfo": "Placeholder information about the TLD URL.", + "openTldURLInNewTab": "Avaa TLD-URL uudessa välilehdessä", "exportResult": "Vie", "exportJson": "JSON", "exportHtml": "HTML", diff --git a/messages/fr.json b/messages/fr.json index 153a8bd1..6569649d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -77,6 +77,8 @@ "filterSeverityLevels": "Filtrer les niveaux de sévérité", "profile": "Profil", "tldURL": "URL du TLD", + "tldURLInfo": "Placeholder information about the TLD URL.", + "openTldURLInNewTab": "Ouvrir l’URL du TLD dans un nouvel onglet", "exportResult": "Exporter", "exportJson": "JSON", "exportHtml": "HTML", diff --git a/messages/nb.json b/messages/nb.json index ad9c3abc..51343631 100644 --- a/messages/nb.json +++ b/messages/nb.json @@ -77,6 +77,8 @@ "filterSeverityLevels": "Filtrer alvorlighetsgrader", "profile": "Profil", "tldURL": "TLD-URL", + "tldURLInfo": "Placeholder information about the TLD URL.", + "openTldURLInNewTab": "Åpne TLD-URL i en ny fane", "exportResult": "Eksportér", "exportJson": "JSON", "exportHtml": "HTML", diff --git a/messages/sl.json b/messages/sl.json index aad4efd5..de59c622 100644 --- a/messages/sl.json +++ b/messages/sl.json @@ -77,6 +77,8 @@ "filterSeverityLevels": "Filter težavnosti", "profile": "Profil", "tldURL": "TLD URL", + "tldURLInfo": "Placeholder information about the TLD URL.", + "openTldURLInNewTab": "Odpri URL TLD v novem zavihku", "exportResult": "Izvozi", "exportJson": "JSON", "exportHtml": "HTML", diff --git a/messages/sv.json b/messages/sv.json index 0831cc59..d8b810be 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -77,6 +77,8 @@ "filterSeverityLevels": "Filtrera allvarlighetsnivå", "profile": "Profil", "tldURL": "TLD URL", + "tldURLInfo": "Placeholder information about the TLD URL.", + "openTldURLInNewTab": "Öppna TLD URL i en ny flik", "exportResult": "Exportera", "exportJson": "JSON", "exportHtml": "HTML", diff --git a/package-lock.json b/package-lock.json index ba9bc912..1c285d5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zonemaster-gui", - "version": "v5.0.1", + "version": "v5.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zonemaster-gui", - "version": "v5.0.1", + "version": "v5.1.0", "license": "BSD-2-Clause", "dependencies": { "@astrojs/node": "^9.5.1", diff --git a/src/lib/client.ts b/src/lib/client.ts index b26dc262..5c07db86 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -120,6 +120,15 @@ export async function getTestResults(testId: string): Promise { return rpc('get_test_results', { id: testId, language: language() }, false); } +export type TldUrlData = { + tld?: string; + url?: string; +}; + +export async function getTldUrl(domain: string): Promise { + return rpc('get_tld_url', { domain }, false); +} + export type ParentZoneData = { ds_list: DSInfo[]; ns_list: Nameservers[]; diff --git a/src/lib/components/DomainTest/ResultInfo.svelte b/src/lib/components/DomainTest/ResultInfo.svelte index 3e93dcd9..c3771014 100644 --- a/src/lib/components/DomainTest/ResultInfo.svelte +++ b/src/lib/components/DomainTest/ResultInfo.svelte @@ -1,5 +1,9 @@ + +

{m.testResultFor()} {data.params.domain}

@@ -138,46 +171,71 @@ }).format(new Date(data.created_at))}
- -
- - + {/if}
-
+
@@ -380,3 +441,10 @@
+ + diff --git a/src/lib/components/Input/Input.svelte b/src/lib/components/Input/Input.svelte index e9c54686..9115d574 100644 --- a/src/lib/components/Input/Input.svelte +++ b/src/lib/components/Input/Input.svelte @@ -11,6 +11,7 @@ onInput?: (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => void; class?: string; matchContentWidth?: boolean; + autoScroll?: boolean; error?: string | null | false; [x: string]: unknown; }; @@ -27,18 +28,66 @@ onInput: onInputCb, class: className = '', matchContentWidth = false, + autoScroll = false, error: defaultError = null, ...restProps }: Props = $props(); let inputWidth = $state(0); let error = $state(defaultError); + let input: HTMLInputElement; + let animationFrame: number | undefined; function onInput(event: Event & { currentTarget: EventTarget & HTMLInputElement }) { error = false; onInputCb?.(event); } + function stopAutoScroll() { + if (animationFrame !== undefined) { + cancelAnimationFrame(animationFrame); + animationFrame = undefined; + } + } + + function startAutoScroll() { + stopAutoScroll(); + + if ( + !autoScroll || + !input || + input.scrollWidth <= input.clientWidth || + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ) { + return; + } + + const maxScroll = input.scrollWidth - input.clientWidth; + const pause = 1200; + const travel = (maxScroll / 20) * 1000; + const cycle = pause * 2 + travel * 2; + const startedAt = performance.now(); + + const animate = (now: number) => { + const elapsed = (now - startedAt) % cycle; + let scrollLeft = 0; + + if (elapsed > pause && elapsed <= pause + travel) { + scrollLeft = ((elapsed - pause) / travel) * maxScroll; + } else if (elapsed > pause + travel && elapsed <= pause * 2 + travel) { + scrollLeft = maxScroll; + } else if (elapsed > pause * 2 + travel) { + scrollLeft = + maxScroll - ((elapsed - pause * 2 - travel) / travel) * maxScroll; + } + + input.scrollLeft = scrollLeft; + animationFrame = requestAnimationFrame(animate); + }; + + animationFrame = requestAnimationFrame(animate); + } + $effect(() => { error = defaultError; @@ -60,13 +109,30 @@ }, 0); } }); + + $effect(() => { + if (!autoScroll || !input) { + return; + } + + const observer = new ResizeObserver(startAutoScroll); + observer.observe(input); + startAutoScroll(); + + return () => { + observer.disconnect(); + stopAutoScroll(); + }; + }); {#if label} {/if} - + input !== document.activeElement && startAutoScroll()} onblur={startAutoScroll} + style:min-width="0" style:width={matchContentWidth ? `${inputWidth}px` : undefined} {...restProps} /> {#if error}
{error}
{/if} diff --git a/src/lib/styles/components/copy.css b/src/lib/styles/components/copy.css index 199bc669..5463a22f 100644 --- a/src/lib/styles/components/copy.css +++ b/src/lib/styles/components/copy.css @@ -11,7 +11,9 @@ transition: background-color 0.2s ease; font-size: var(--button-font-size); - &:hover { + &:hover, + &:focus { background: var(--color-palette-main-90); + color: var(--color-palette-white); } } diff --git a/src/lib/styles/components/popover.css b/src/lib/styles/components/popover.css index dfc6f398..324366fe 100644 --- a/src/lib/styles/components/popover.css +++ b/src/lib/styles/components/popover.css @@ -49,3 +49,41 @@ } } } + +/* Anchor URL popovers to the row so long URLs and wrapped buttons stay within it. */ +.zm-popover-row { + position: relative; +} + +.zm-popover--start, +.zm-popover--end { + position: static; + + .zm-popover__content { + /* Directly below the button, while still anchored to the row */ + top: auto; + /* Cover buttons that wrapped onto the next line */ + z-index: 1; + box-sizing: border-box; + width: max-content; + max-width: 100%; + } +} + +.zm-popover--start .zm-popover__content { + left: 0; + right: auto; + + &:after, + &:before { + left: calc(var(--rhythm) * 1.75); + } +} + +.zm-popover--end .zm-popover__content { + /* The button may wrap away from the right edge of the row. */ + &:after, + &:before { + display: none; + } +} diff --git a/src/themes/default/styles/utilities/font.css b/src/themes/default/styles/utilities/font.css new file mode 100644 index 00000000..361daddc --- /dev/null +++ b/src/themes/default/styles/utilities/font.css @@ -0,0 +1 @@ +.u-font-size-medium { font-size: var(--font-xs); } diff --git a/src/themes/default/styles/utilities/manifest.css b/src/themes/default/styles/utilities/manifest.css index d905f5b3..c6a69ee0 100644 --- a/src/themes/default/styles/utilities/manifest.css +++ b/src/themes/default/styles/utilities/manifest.css @@ -1,3 +1,4 @@ @import 'align.css'; @import 'hide.css'; @import 'margin.css'; +@import 'font.css'; diff --git a/src/themes/default/styles/utilities/margin.css b/src/themes/default/styles/utilities/margin.css index fa37492a..cdcbac5c 100644 --- a/src/themes/default/styles/utilities/margin.css +++ b/src/themes/default/styles/utilities/margin.css @@ -30,3 +30,7 @@ margin-left: auto; margin-right: auto; } + +.u-m-b-1 { + margin-bottom: var(--spacing-xs); +}