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
6 changes: 6 additions & 0 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,12 @@ ExplorationActionControl.indicator?: 'chevron' | 'ellipsis' — Chart labels use
ExplorationActionControl.ref?: Ref<HTMLButtonElement> — Shares this menu trigger with an item's context and keyboard actions.
ExplorationActionControl.className?: string
ExplorationActionControlProps (type, @altertable/data-app-react/ui): = props listed as ExplorationActionControl.*
ExternalLink (component, @altertable/data-app-react): (props: ExternalLinkProps) — Open HTTPS links through the host when embedded, or a native new tab when standalone.
ExternalLink.href: string
ExternalLink.children: ReactNode
ExternalLink.className?: string
ExternalLink.title?: string
ExternalLinkProps (type, @altertable/data-app-react): = props listed as ExternalLink.*
FilterActions (component, @altertable/data-app-react/ui): (props: FilterActionsProps)
FilterActions.onClear?: (() => void)
FilterActions.onApply: () => void [variant 1 of 2]
Expand Down
6 changes: 6 additions & 0 deletions dev/fixtures/bundle-host.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type DataAppLogger,
type DataAppStatus,
createNavigationHandler,
createExternalLinkHandler,
createSqlQueryHandler,
} from '@altertable/data-app-embed';
import { DataAppBridge } from '@altertable/data-app-embed/react';
Expand All @@ -23,6 +24,7 @@ import {
annotationSendRoute,
annotationUpdateRoute,
createMessageRouter,
openExternalLinkRoute,
defineMessageRoute,
sqlQueryRoute,
DataSourceError,
Expand Down Expand Up @@ -219,6 +221,8 @@ function Host() {
...(hostOptions.has('lazy') ? { loading: 'lazy' as const } : {}),
};
const forward = createHttpTransport();
const [openExternalLink] = useState(() => createExternalLinkHandler());

const fileExportRoute = defineMessageRoute({
input(value: unknown): { filename: string; blob: Blob } {
if (!value || typeof value !== 'object')
Expand Down Expand Up @@ -260,6 +264,7 @@ function Host() {
'annotation:send': annotationSendRoute,
'annotation:update': annotationUpdateRoute,
'data:sql': sqlQueryRoute,
'navigation:openExternal': openExternalLinkRoute,
'export:csv': fileExportRoute,
'export:zip': fileExportRoute,
},
Expand All @@ -275,6 +280,7 @@ function Host() {
if (annotationFailure) throw new Error('Fixture failure');
return annotationsHost.handlers['annotation:draft'](draft);
},
'navigation:openExternal': openExternalLink,
'export:csv': downloadExport,
'export:zip': downloadExport,
'data:sql': createSqlQueryHandler(async () => ({
Expand Down
23 changes: 23 additions & 0 deletions docs/app-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,29 @@ Keep credentials, authorization, and enforced query limits in the host/backend.
The starter supplies transport and mounting; change them only for an explicit
integration task.

## Link to external pages

Use `<ExternalLink>` for HTTPS destinations in widget content:

```tsx
import { ExternalLink } from '@altertable/data-app-react';

function ReportLink() {
return (
<ExternalLink href="https://example.com/report">
View full report
</ExternalLink>
);
}
```

For programmatic navigation, import `navigateNewTab`, `navigateNewWindow`, or
`navigateNewWindowOrTab` from `@altertable/data-app/client`. For example,
`navigateNewWindowOrTab(url, event)` accepts a DOM or React event: Shift requests
a window, Ctrl/Cmd requests a tab, and an unmodified event does nothing.

Embedded hosts must enable the [external link route](embed.md#external-links).

## Verify the app

Run the authoring workflow's validation, then inspect the app:
Expand Down
38 changes: 38 additions & 0 deletions docs/embed.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,44 @@ Host Back/Forward events publish opaque host state through the bridge. The app's
optional navigation adapter interprets search/hash and synchronizes controls;
opaque apps keep that state in memory rather than modifying their document URL.

## External links

Register the external link route to let apps request HTTPS navigation without
relaxing the iframe sandbox:

```ts
import {
createMessageRouter,
openExternalLinkRoute,
} from '@altertable/data-app/contract';
import { createExternalLinkHandler } from '@altertable/data-app-embed';

const router = createMessageRouter(
{ 'navigation:openExternal': openExternalLinkRoute },
{
'navigation:openExternal': createExternalLinkHandler({
allowedOrigins: ['https://example.com'],
}),
}
);
// Pass router.dispatch as the bridge's onMessage handler.
```

To use an existing modifier-based host navigation helper, register it directly:

```ts
const openExternalLink = createExternalLinkHandler({
navigate: navigateNewWindowOrTab,
});
// Register openExternalLink for navigation:openExternal in the host router.
```

Here `navigateNewWindowOrTab` is the host's `(url, event)` navigation helper.
Use the frontend helper, not the app-side bridge client with the same name.

Choose permitted origins according to the host's data policy: URLs can carry app
data, and the initial origin check does not constrain redirects at the destination.

## Status and diagnostics

`onStatusChange` receives `connecting`, `connected`, `ready`, `failed`, or
Expand Down
6 changes: 6 additions & 0 deletions packages/data-app-bun/src/preview/host.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
createDataAppRepairSession,
createNavigationHandler,
createExternalLinkHandler,
createSqlQueryHandler,
type DataAppStatus,
} from '@altertable/data-app-embed';
Expand All @@ -11,6 +12,7 @@ import {
import {
DataSourceError,
createMessageRouter,
openExternalLinkRoute,
defineMessageRoute,
navigationUpdateRoute,
sqlQueryRoute,
Expand All @@ -19,6 +21,8 @@ import { downloadBlob } from '@altertable/data-app/internal';
import { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';

const openExternalLink = createExternalLinkHandler();

const fileExportRoute = defineMessageRoute({
input(value: unknown): { filename: string; blob: Blob } {
const file = value as { filename?: unknown; blob?: unknown } | null;
Expand All @@ -41,6 +45,7 @@ const router = createMessageRouter(
{
'data:sql': sqlQueryRoute,
'navigation:update': navigationUpdateRoute,
'navigation:openExternal': openExternalLinkRoute,
'export:csv': fileExportRoute,
'export:zip': fileExportRoute,
},
Expand All @@ -63,6 +68,7 @@ const router = createMessageRouter(
},
})),
'navigation:update': createNavigationHandler(),
'navigation:openExternal': openExternalLink,
'export:csv': download,
'export:zip': download,
}
Expand Down
127 changes: 127 additions & 0 deletions packages/data-app-embed/src/external-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import {
openExternalLinkRoute,
resolveExternalLinkTarget,
MessageRoutingError,
type OpenExternalLink,
type ExternalLinkResult,
type MessageContext,
} from '@altertable/data-app/contract';

/** Show a host-owned confirmation before external navigation. No iframe permissions are added. */
export function createExternalLinkHandler({
window: host = window,
allowedOrigins,
navigate,
}: {
window?: Window;
/** Exact HTTPS origins. Omit to allow any HTTPS destination after confirmation. */
allowedOrigins?: readonly string[];
/** Called synchronously on the host confirmation click instead of native navigation.
* Receives an undispatched, synthetic KeyboardEvent with the original modifier keys.
* Explicit tab/window requests map to Ctrl/Shift so modifier-based helpers work directly.
* The callback owns popup opening and opener/referrer protection; do not await before opening.
*/
navigate?: (url: string, event: KeyboardEvent) => void;
} = {}) {
const origins =
allowedOrigins &&
new Set(
allowedOrigins.map(origin => {
const url = new URL(origin);
if (url.protocol !== 'https:' || url.origin !== origin)
throw new Error('Expected an exact HTTPS origin.');
return origin;
})
);
let pending = false;
return async function openExternalLink(
input: OpenExternalLink,
{ signal }: MessageContext
): Promise<ExternalLinkResult> {
signal.throwIfAborted();
const request = openExternalLinkRoute.input(input);
const { url } = request;
if (origins && !origins.has(new URL(url).origin))
throw new MessageRoutingError(
'forbidden',
'This external destination is not allowed.'
);
const target = resolveExternalLinkTarget(request);
if (!target) return 'ignored';
const keys =
request.target === 'event'
? request.keys
: {
shiftKey: target === 'window',
ctrlKey: target === 'tab',
metaKey: false,
};
if (pending)
throw new MessageRoutingError(
'external_link_busy',
'An external link is already awaiting confirmation.'
);
pending = true;
const document = host.document;
const previousFocus = document.activeElement;
const dialog = document.createElement('dialog');
dialog.setAttribute('aria-label', 'Open external link');
const description = document.createElement('p');
description.textContent = 'This app wants to open an external website:';
const destination = document.createElement('p');
destination.textContent = url;
destination.style.overflowWrap = 'anywhere';
const link = document.createElement('a');
link.href = url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = `Open ${new URL(url).host}`;
const cancel = document.createElement('button');
cancel.textContent = 'Cancel';
cancel.autofocus = true;
dialog.append(description, destination, link, ' ', cancel);
document.body.append(dialog);
let cleanup: (() => void) | undefined;
try {
return await new Promise<ExternalLinkResult>((resolve, reject) => {
function abort() {
reject(signal.reason);
}
signal.addEventListener('abort', abort, { once: true });
dialog.addEventListener('close', () => resolve('cancelled'));
cancel.addEventListener('click', () => dialog.close());
function open(event: MouseEvent) {
if (event.type === 'auxclick' && event.button !== 1) return;
event.preventDefault();
try {
if (navigate) navigate(url, new KeyboardEvent('navigation', keys));
else
host.open(
url,
'_blank',
target === 'window'
? 'popup,noopener,noreferrer'
: 'noopener,noreferrer'
);
} catch (error) {
reject(error);
return;
}
resolve('opened');
}
link.addEventListener('click', open);
link.addEventListener('auxclick', open);
// Remove the abort listener when the dialog settles, including showModal failures.
cleanup = () => signal.removeEventListener('abort', abort);
dialog.showModal();
});
} finally {
cleanup?.();
dialog.close();
dialog.remove();
pending = false;
if (previousFocus && 'focus' in previousFocus)
(previousFocus as HTMLElement).focus();
}
};
}
2 changes: 2 additions & 0 deletions packages/data-app-embed/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ export {
type DataAppRepairOptions,
type DataAppRepairStatus,
} from './repair.ts';

export { createExternalLinkHandler } from './external-link.ts';
2 changes: 2 additions & 0 deletions packages/data-app-react/src/authoring/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,5 @@ export type { SectionHeadingProps } from '../ui/SectionHeading.tsx';
export { DateDisplay } from '../ui/DateDisplay.tsx';
export type { DateDisplayProps } from '../ui/DateDisplay.tsx';
export { useDateFormatting } from '../date-formatting.ts';

export { ExternalLink, type ExternalLinkProps } from '../ui/ExternalLink.tsx';
2 changes: 2 additions & 0 deletions packages/data-app-react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,5 @@ export {
injectDataAppAnnotationStyles,
} from './host/index.ts';
export type { AnnotationBarHandle, AnnotationBarProps } from './host/index.ts';

export { ExternalLink, type ExternalLinkProps } from './authoring/index.ts';
Loading