Zoomable frame (#1029)

* add zoomable frame component; #986

* remove viewport demo component

* update changelog

* fix code demos

* update zoomable iframes with theme changes
This commit is contained in:
Cory LaViska
2025-06-05 17:34:25 -04:00
committed by GitHub
parent d2e32a0a53
commit 0d09037916
42 changed files with 528 additions and 640 deletions

View File

@@ -125,6 +125,7 @@
"noreferrer",
"novalidate",
"Numberish",
"nums",
"oklab",
"oklch",
"onscrollend",
@@ -139,6 +140,7 @@
"progressbar",
"radiogroup",
"Railsbyte",
"referrerpolicy",
"remixicon",
"reregister",
"resizer",
@@ -165,6 +167,7 @@
"slotchange",
"smartquotes",
"spacebar",
"srcdoc",
"stylesheet",
"svgs",
"Tabbable",

View File

@@ -15,9 +15,8 @@
{% endfor %}
</div>
</fieldset>
<wa-viewport-demo viewport="1000">
<iframe srcdoc="" id="page_slots_iframe"></iframe>
</wa-viewport-demo>
<wa-zoomable-frame srcdoc="" zoom="0.5" id="page_slots_iframe"></wa-zoomable-frame>
</div>
<script type="module">

View File

@@ -174,6 +174,7 @@
<li><a href="/docs/components/tooltip/">Tooltip</a></li>
<li><a href="/docs/components/tree/">Tree</a></li>
<li><a href="/docs/components/tree-item/">Tree Item</a></li>
<li><a href="/docs/components/zoomable-frame">Zoomable Frame</a></li>
{# PLOP_NEW_COMPONENT_PLACEHOLDER #}
</ul>
</wa-details>

View File

@@ -25,7 +25,6 @@ export function codeExamplesPlugin(options = {}) {
const pre = code.closest('pre');
const hasButtons = !code.classList.contains('no-buttons');
const isOpen = code.classList.contains('open') || !hasButtons;
const isViewportDemo = code.classList.contains('viewport');
const noEdit = code.classList.contains('no-edit');
const id = `code-example-${uuid().slice(-12)}`;
let preview = pre.textContent;
@@ -35,29 +34,10 @@ export function codeExamplesPlugin(options = {}) {
root.querySelectorAll('script').forEach(script => script.setAttribute('type', 'module'));
preview = root.toString();
const escapedHtml = markdown.utils.escapeHtml(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Awesome Demo</title>
<link rel="stylesheet" href="https://early.webawesome.com/webawesome@[version]/dist/styles/themes/default.css" />
<link rel="stylesheet" href="https://early.webawesome.com/webawesome@[version]/dist/styles/webawesome.css" />
<script type="module" src="https://early.webawesome.com/webawesome@[version]/dist/webawesome.loader.js"></script>
</head>
<body>
${preview}
</body>
</html>
`);
const codeExample = parse(`
<div class="code-example ${isOpen ? 'open' : ''} ${isViewportDemo ? 'is-viewport-demo' : ''}">
<div class="code-example ${isOpen ? 'open' : ''}">
<div class="code-example-preview">
${isViewportDemo ? ` <wa-viewport-demo><iframe srcdoc="${escapedHtml}"></iframe></wa-viewport-demo>` : preview}
${preview}
<div class="code-example-resizer" aria-hidden="true">
<wa-icon name="grip-lines-vertical"></wa-icon>
</div>

View File

@@ -1,4 +1,4 @@
import { domChange, nextFrame, ThemeAspect } from './theme-picker.js';
import { domChange, ThemeAspect } from './theme-picker.js';
const presetTheme = new ThemeAspect({
defaultValue: 'default',
@@ -33,7 +33,7 @@ const presetTheme = new ThemeAspect({
if (instant) {
// If no VT, delay by 1 frame to make it smoother
await nextFrame();
await new Promise(requestAnimationFrame);
}
oldStylesheet.remove();

View File

@@ -1,14 +1,11 @@
import { domChange } from './util/dom-change.js';
export { domChange };
export function nextFrame() {
return new Promise(resolve => requestAnimationFrame(resolve));
}
export class ThemeAspect {
constructor(options) {
Object.assign(this, options);
this.set();
this.syncIframes();
// Update when local storage changes.
// That way changes in one window will propagate to others (including iframes).
@@ -67,6 +64,30 @@ export class ThemeAspect {
this.syncUI();
}
async syncIframes() {
await customElements.whenDefined('wa-zoomable-frame');
await new Promise(requestAnimationFrame);
// Sync to wa-zoomable-frame iframes
let dark = this.computedValue === 'dark';
for (let zoomableEl of document.querySelectorAll('wa-zoomable-frame')) {
const iframe = zoomableEl.iframe;
const applyToIframe = () => {
try {
iframe.contentDocument.documentElement.classList.toggle('wa-dark', dark);
} catch (e) {
// Silently handle access issues
}
};
// Try immediately
applyToIframe();
// Also listen for load in case it wasn't ready
iframe.addEventListener('load', applyToIframe, { once: true });
}
}
syncUI(container = document) {
for (let picker of container.querySelectorAll(this.picker)) {
picker.setAttribute('value', this.value);
@@ -87,27 +108,22 @@ const colorScheme = new ThemeAspect({
},
applyChange() {
// Toggle the dark mode class
domChange(() => {
// Toggle the dark mode class with view transition
const updateTheme = () => {
let dark = this.computedValue === 'dark';
document.documentElement.classList.toggle(`wa-dark`, dark);
document.documentElement.dispatchEvent(new CustomEvent('wa-color-scheme-change', { detail: { dark } }));
syncViewportDemoColorSchemes();
});
this.syncIframes();
};
if (document.startViewTransition) {
document.startViewTransition(() => domChange(updateTheme));
} else {
domChange(updateTheme);
}
},
});
function syncViewportDemoColorSchemes() {
const isDark = document.documentElement.classList.contains('wa-dark');
// Update viewport demo color schemes in code examples
document.querySelectorAll('.code-example.is-viewport-demo wa-viewport-demo').forEach(demo => {
demo.querySelectorAll('iframe').forEach(iframe => {
iframe.contentWindow.document.documentElement?.classList?.toggle('wa-dark', isDark);
});
});
}
// Update the color scheme when the preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => colorScheme.set());
@@ -121,12 +137,3 @@ document.addEventListener('keydown', event => {
colorScheme.set(colorScheme.get() === 'dark' ? 'light' : 'dark');
}
});
// When rendering a code example with a viewport demo, set the theme to match initially
document.querySelectorAll('.code-example.is-viewport-demo wa-viewport-demo iframe').forEach(iframe => {
const isDark = document.documentElement.classList.contains('wa-dark');
iframe.addEventListener('load', () => {
iframe.contentWindow.document.documentElement?.classList?.toggle('wa-dark', isDark);
});
});

View File

@@ -0,0 +1,79 @@
---
title: Zoomable Frame
layout: component
---
```html {.example}
<wa-zoomable-frame src="https://backers.webawesome.com/" zoom="0.5">
</wa-zoomable-frame>
```
## Examples
### Loading external content
Use the `src` attribute to embed external websites or resources. The URL must be accessible, and cross-origin restrictions may apply due to the Same-Origin Policy, potentially limiting access to the iframe's content.
```html
<wa-zoomable-frame src="https://example.com/">
</wa-zoomable-frame>
```
The zoomable frame fills 100% width by default with a 16:9 aspect ratio. Customize this using the `aspect-ratio` CSS property.
```html
<wa-zoomable-frame src="https://example.com/" style="aspect-ratio: 4/3;">
</wa-zoomable-frame>
```
Use the `srcdoc` attribute or property to display custom HTML content directly within the iframe, perfect for rendering inline content without external resources.
```html
<wa-zoomable-frame srcdoc="<html><body><h1>Hello, World!</h1><p>This is inline content.</p></body></html>">
</wa-zoomable-frame>
```
:::info
When both `src` and `srcdoc` are specified, `srcdoc` takes precedence.
:::
### Controlling zoom behavior
Set the `zoom` attribute to control the frame's zoom level. Use `1` for 100%, `2` for 200%, `0.5` for 50%, and so on.
Define specific zoom increments with the `zoom-levels` attribute using space-separated percentages and decimal values like `zoom-levels="0.25 0.5 75% 100%"`.
```html {.example}
<wa-zoomable-frame
src="https://backers.webawesome.com/"
zoom="0.5"
zoom-levels="50% 0.75 100%"
>
</wa-zoomable-frame>
```
### Hiding zoom controls
Add the `without-controls` attribute to hide the zoom control interface from the frame.
```html {.example}
<wa-zoomable-frame
src="https://backers.webawesome.com/"
without-controls
zoom="0.5"
>
</wa-zoomable-frame>
```
### Preventing user interaction
Apply the `without-interaction` attribute to make the frame non-interactive. Note that this prevents keyboard navigation into the frame, which may impact accessibility for some users.
```html {.example}
<wa-zoomable-frame
src="https://backers.webawesome.com/"
zoom="0.5"
without-interaction
>
</wa-zoomable-frame>
```

View File

@@ -40,6 +40,7 @@ During the alpha period, things might break! We take breaking changes very serio
- 🚨 BREAKING: removed the `hint` property and slot from `<wa-radio>`; please apply hints directly to `<wa-radio-group>` instead
- 🚨 BREAKING: removed `<wa-icon-button>`; use `<wa-button><wa-icon name="..." label="..."></wa-icon></wa-button>` instead
- Added a new free component: `<wa-popover>` (#2 of 14 per stretch goals)
- Added a new free component: `<wa-zoomable-frame>` (#3 of 14 per stretch goals)
- Added a `min-block-size` to `<wa-divider orientation="vertical">` to ensure the divider is visible regardless of container height [issue:675]
- Added support for `name` in `<wa-details>` for exclusively opening one in a group
- Added `--checked-icon-scale` to `<wa-checkbox>`

View File

@@ -1,147 +0,0 @@
:host {
--viewport-background-color: var(--wa-color-surface-default, canvas);
--viewport-resize: both;
--viewport-min-width: 10em;
--viewport-min-height: 5em;
--viewport-max-width: 100%;
--viewport-padding: var(--wa-space-2xl, 2rem);
--viewport-initial-aspect-ratio: 16 / 9;
--viewport-bezel-width: 0.25em;
display: block;
/* Needed for measuring the available space */
contain: inline-size;
container-type: inline-size;
container-name: host;
}
[part~='frame'] {
--zoom: 1; /* overridden by JS */
--available-width: calc((100cqw - var(--offset-inline, 0px)));
--iframe-manual-aspect-ratio: calc(var(--iframe-manual-width-px) / var(--iframe-manual-height-px));
--iframe-manual-width: calc(var(--iframe-manual-width-px) * 1px * var(--zoom));
--iframe-manual-height: calc(var(--iframe-manual-height-px) * 1px * var(--zoom));
--width: var(--iframe-manual-width, var(--available-width));
--height-auto: calc(var(--width) / (var(--aspect-ratio)));
--_aspect-ratio: calc(var(--viewport-width-px) / var(--viewport-height-px));
--aspect-ratio: var(--_aspect-ratio, var(--viewport-initial-aspect-ratio));
display: flex;
flex-flow: column;
align-items: start;
width: fit-content;
height: fit-content;
/* Style frame like a window */
border: var(--viewport-bezel-width) solid transparent;
border-radius: var(--wa-border-radius-m);
/* Window-like frame styling */
--button-params: 0.4em / 0.5em 0.5em border-box;
background:
radial-gradient(circle closest-side, var(--wa-color-red-60) 80%, var(--wa-color-red-50) 98%, transparent) 0.4em
var(--button-params),
radial-gradient(circle closest-side, var(--wa-color-yellow-80) 80%, var(--wa-color-yellow-70) 98%, transparent)
1.1em var(--button-params),
radial-gradient(circle closest-side, var(--wa-color-green-70) 80%, var(--wa-color-green-60) 98%, transparent) 1.8em
var(--button-params),
var(--wa-color-gray-95);
background-repeat: no-repeat;
&.resized {
aspect-ratio: var(--iframe-manual-aspect-ratio);
}
background-color: var(--wa-color-neutral-fill-normal);
/* User has not yet resized the viewport */
&:not(.resized) ::slotted(iframe),
&:not(.resized) slot {
/* Will only be set if we have BOTH width and height */
aspect-ratio: var(--aspect-ratio);
}
}
slot {
display: block;
overflow: clip;
width: var(--width);
max-width: var(--available-width);
height: var(--iframe-manual-height, var(--height-auto));
}
::slotted(iframe) {
display: block;
flex: auto;
scale: var(--zoom);
transform-origin: top left;
resize: var(--viewport-resize);
border-radius: var(--wa-border-radius-m);
overflow: auto;
/* The width and height specified here are only applied if the iframe is not manually resized */
width: calc(var(--available-width) / var(--zoom));
height: calc(var(--height-auto) / var(--zoom));
min-width: calc(var(--viewport-min-width, 10em) / var(--zoom));
max-width: calc(var(--available-width) / var(--zoom)) !important;
min-height: calc(var(--viewport-min-height) / var(--zoom));
/* Divide with var(--zoom) to get lengths that stay constant regardless of zoom level */
border: calc(1px / var(--zoom)) solid var(--wa-color-gray-90);
}
[part~='controls'] {
display: flex;
align-items: center;
align-self: end;
gap: 0.3em;
margin-top: -0.2em;
font-size: var(--wa-font-size-xs);
padding-block-end: 0.25em;
padding-inline: 1em 0.2em;
white-space: nowrap;
/* Until we can implement info that is not lying, we dont show it when it's lying */
.needs-internal-zoom & > * {
opacity: 0 !important;
pointer-events: none;
}
.dimensions {
word-spacing: -0.15em;
margin-inline-end: 1em;
}
wa-icon {
font-size: 85%;
}
wa-button {
line-height: 1;
&::part(base) {
padding: 0;
height: 1em;
width: 1em;
}
}
.zoom {
display: flex;
align-items: center;
gap: 0.3em;
}
[part~='zoom-in'],
[part~='zoom-in']::part(base) {
cursor: zoom-in;
}
[part~='zoom-out'],
[part~='zoom-out']::part(base) {
cursor: zoom-out;
}
}

View File

@@ -1,432 +0,0 @@
import type { PropertyValues } from 'lit';
import { html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { getComputedStyle } from '../../internal/computed-style.js';
import { watch } from '../../internal/watch.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import '../button/button.js';
import styles from './viewport-demo.css';
export interface ViewportDimensions {
width: number;
height?: number;
}
export function isViewportDimensions(
viewport: boolean | ViewportDimensions | undefined,
): viewport is ViewportDimensions {
return Boolean(viewport) && typeof viewport === 'object' && 'width' in viewport;
}
export const viewportPropertyConverter = {
fromAttribute(value: string | null) {
if (value === null) {
return false;
}
if (value === '') {
return true;
}
const [width, height] = value.trim().split(/\s*x\s*/);
const ret: ViewportDimensions = { width: parseFloat(width) };
if (height) {
ret.height = parseFloat(height);
}
return ret;
},
toAttribute(value: boolean | ViewportDimensions) {
if (value === false) {
return null;
}
if (value === true) {
return '';
}
return `${value.width} x ${value.height}`;
},
};
/**
* @summary Viewport demos can be used to display an iframe as a resizable, zoomable preview.
* @documentation https://backers.webawesome.com/docs/components/viewport-demo
* @status experimental
* @since 3.0
*
* @dependency wa-button
*
* @slot - The iframe (usually an `<iframe>` element).
*
* @csspart frame - The visible frame around the viewport.
*
* @cssproperty --viewport-initial-aspect-ratio - The initial aspect ratio of the viewport, when the `viewport` attribute is used. Defaults to `16 / 9`.
* @cssproperty --viewport-bezel-width - The width of the bezel around the viewport. Defaults to `0.25em`.
* @cssproperty --viewport-background-color - The background color of the viewport. Defaults to `var(--wa-color-surface-default, canvas)`.
* @cssproperty --viewport-resize - The resize behavior of the viewport. Defaults to `both`.
* @cssproperty --viewport-min-width - The minimum width of the viewport. Defaults to `2em`.
* @cssproperty --viewport-max-width - The maximum width of the viewport. Defaults to `100%`. Anything over 100% will be clipped.
* @cssproperty --viewport-padding - The padding of the viewport. Defaults to `var(--wa-space-2xl, 2rem)`.
*
*/
@customElement('wa-viewport-demo')
export default class WaViewportDemo extends WebAwesomeElement {
static css = styles;
@query('[part~=frame]')
private viewportElement: HTMLElement;
/** Renders in an iframe */
@property({
reflect: true,
converter: {
fromAttribute(value: string | null) {
if (value === null) {
return false;
}
if (value === '') {
return true;
}
const [width, height] = value.trim().split(/\s*x\s*/);
const ret: ViewportDimensions = { width: parseFloat(width) };
if (height) {
ret.height = parseFloat(height);
}
return ret;
},
toAttribute(value: boolean | ViewportDimensions) {
if (value === false) {
return null;
}
if (value === true) {
return '';
}
return `${value.width} x ${value.height}`;
},
},
})
viewport?: boolean | ViewportDimensions;
@state()
initialAspectRatio = 16 / 9;
@property()
zoom: number = 1;
@state()
public defaultZoom: number = 1;
/** Number of steps zoomed in/out */
@state()
private zoomLevel: number = 0;
/** Actual final applied zoom */
@state()
public computedZoom: number = 1;
@state()
private iframe: HTMLIFrameElement;
@state()
private innerWidth: number = 0;
@state()
private innerHeight: number = 0;
@state()
private offsetInline: number = 0;
@state()
private availableWidth = 0;
@state()
private contentWindow: Window | null;
@state()
private iframeManualWidth: number | undefined;
@state()
private iframeManualHeight: number | undefined;
private resizeObserver: ResizeObserver;
connectedCallback(): void {
super.connectedCallback();
this.handleViewportChange();
}
disconnectedCallback() {
super.disconnectedCallback();
this.unobserveResize();
}
private observeResize() {
this.resizeObserver ??= new ResizeObserver(records => this.handleResize(records));
this.resizeObserver.observe(this);
this.updateComplete.then(() => {
if (this.iframe) {
this.resizeObserver.observe(this.iframe);
}
});
}
private unobserveResize() {
this.resizeObserver?.unobserve(this);
this.resizeObserver?.unobserve(this.iframe);
}
// Called when this.iframe.contentWindow changes
private handleIframeLoad() {
if (this.iframe.contentWindow) {
this.contentWindow = this.iframe.contentWindow;
this.updateZoom();
this.handleViewportResize();
this.contentWindow.addEventListener('resize', () => this.handleViewportResize());
}
}
private updateAvailableWidth() {
// This is only needed for isolated demos
if (this.viewport && globalThis.window && this.iframe) {
const offsets = {
host: getHorizontalOffsets(getComputedStyle(this)),
frame: getHorizontalOffsets(getComputedStyle(this.viewportElement)),
iframe: getHorizontalOffsets(getComputedStyle(this.iframe)),
};
this.offsetInline = offsets.host.inner + offsets.frame.all + offsets.iframe.all;
this.availableWidth = this.clientWidth - this.offsetInline;
}
}
/** Called when the user resizes the iframe */
private handleIframeResize() {
const { width, height } = this.iframe.style;
this.iframeManualWidth = (width && getNumber(width)) || undefined;
this.iframeManualHeight = (height && getNumber(height)) || undefined;
}
/** Gets called when the host gets resized */
private handleResize(records: ResizeObserverEntry[]) {
// This is only needed for isolated demos
for (const record of records) {
if (record.target === this) {
if (this.viewport && globalThis.window) {
this.updateAvailableWidth();
}
} else if (record.target === this.iframe) {
this.handleIframeResize();
}
}
}
/** Zoom in by one step */
public zoomIn() {
this.zoomLevel++;
}
/** Zoom out by one step */
public zoomOut() {
this.zoomLevel--;
}
private updateZoom() {
const usesDefaultZoom = this.zoom === this.defaultZoom && !this.hasAttribute('zoom');
if (isViewportDimensions(this.viewport)) {
if (!this.availableWidth) {
this.updateAvailableWidth();
}
// Zoom level = available width / virtual width
if (!this.availableWidth) {
// Abort mission
return;
}
this.defaultZoom = this.availableWidth / this.viewport.width;
this.updateComplete.then(() => this.handleViewportResize());
} else {
this.defaultZoom = 1;
}
if (usesDefaultZoom) {
this.zoom = this.defaultZoom;
}
if (this.zoomLevel === 0) {
this.computedZoom = this.zoom;
} else {
const zoom = Number(this.zoom.toPrecision(2));
this.computedZoom = zoom + 0.1 * this.zoomLevel;
}
}
private handleViewportResize() {
this.innerWidth = this.iframe.clientWidth;
this.innerHeight = this.iframe.clientHeight;
}
@watch('viewport')
handleViewportChange() {
if (this.viewport) {
if (isViewportDimensions(this.viewport)) {
this.initialAspectRatio = this.viewport.height ? this.viewport.width / this.viewport.height : 16 / 9;
}
this.observeResize();
} else {
this.unobserveResize();
}
}
updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (changedProperties.has('iframe' as keyof WaViewportDemo)) {
this.observeResize();
}
if (['zoomLevel', 'availableWidth', 'viewport'].some(p => changedProperties.has(p as keyof WaViewportDemo))) {
this.updateZoom();
}
if (changedProperties.has('computedZoom')) {
if (this.iframeManualWidth !== undefined || this.iframeManualHeight !== undefined) {
// These inline styles have been created based on the previous zoom level
// We need to convert them manually and reapply them
this.unobserveResize(); // pause the observer
const previousZoom = changedProperties.get('computedZoom') as number;
if (this.iframeManualWidth !== undefined) {
const width = (this.iframeManualWidth * previousZoom) / this.computedZoom;
this.iframe.style.width = width + 'px';
this.iframeManualWidth = width;
}
if (this.iframeManualHeight !== undefined) {
const height = (this.iframeManualHeight * previousZoom) / this.computedZoom;
this.iframe.style.height = height + 'px';
this.iframeManualHeight = height;
}
this.observeResize();
}
}
}
render() {
const width = this.innerWidth || (isViewportDimensions(this.viewport) ? this.viewport.width : 0);
const height = this.innerHeight || (isViewportDimensions(this.viewport) ? this.viewport.height : 0);
const dimensions = width && height ? html`<span class="dimensions">${width} × ${height}</span>` : '';
const viewportStyle: Record<string, string | number> = {
'--zoom': this.computedZoom,
'--offset-inline': this.offsetInline + 'px',
};
const resized = Boolean(this.iframeManualWidth || this.iframeManualHeight);
const viewportClasses = {
'resized-width': Boolean(this.iframeManualWidth),
'resized-height': Boolean(this.iframeManualHeight),
resized,
};
if (this.iframeManualWidth) {
viewportStyle['--iframe-manual-width-px'] = this.iframeManualWidth;
}
if (this.iframeManualHeight) {
viewportStyle['--iframe-manual-height-px'] = this.iframeManualHeight;
}
if (isViewportDimensions(this.viewport)) {
viewportStyle['--viewport-width-px'] = this.viewport.width;
if (this.viewport.height) {
viewportStyle['--viewport-height-px'] = this.viewport.height;
}
}
return html`
<div id="viewport" part="frame" style=${styleMap(viewportStyle)} class=${classMap(viewportClasses)}>
<span part="controls">
${resized
? html`<wa-button
appearance="plain"
@click=${() => this.iframe.removeAttribute('style')}
part="undo button"
>
<wa-icon name="arrow-rotate-left" variant="regular" label="Revert resizing"></wa-icon>
</wa-button>`
: ''}
${dimensions}
<span class="zoom">
<wa-button appearance="plain" @click=${() => this.zoomOut()} part="zoom-out button">
<wa-icon name="square-minus" variant="regular" label="Zoom out"></wa-icon>
</wa-button>
<span class="zoom-level"> ${Math.round(this.computedZoom * 100)}% </span>
<wa-button appearance="plain" @click=${() => this.zoomIn()} part="zoom-in button">
<wa-icon name="square-plus" variant="regular" label="Zoom in"></wa-icon>
</wa-button>
</span>
</span>
<slot @slotchange=${this.handleSlotChange}></slot>
</div>
`;
}
private handleSlotChange(event: Event) {
const slot = event.target as HTMLSlotElement;
this.iframe = slot.assignedElements()[0] as HTMLIFrameElement;
if (this.iframe) {
this.handleIframeLoad();
this.iframe.addEventListener('load', () => this.handleIframeLoad());
}
}
}
declare global {
interface HTMLElementTagNameMap {
'wa-viewport-demo': WaViewportDemo;
}
}
// Private helpers
/**
* Parse a string into a number, or return 0 if it's not a number
*/
function getNumber(value: string | number): number {
return (typeof value === 'string' ? parseFloat(value) : value) || 0;
}
interface HorizontalOffsets {
padding: number;
border: number;
margin: number;
inner: number;
all: number;
}
const noOffsets: HorizontalOffsets = { padding: 0, border: 0, margin: 0, inner: 0, all: 0 };
/**
* Get the horizontal padding and border widths of an element
*/
function getHorizontalOffsets(cs: CSSStyleDeclaration | null): HorizontalOffsets {
if (!cs) {
return noOffsets;
}
const padding = getNumber(cs.paddingLeft) + getNumber(cs.paddingRight);
const border = getNumber(cs.borderLeftWidth) + getNumber(cs.borderRightWidth);
const margin = getNumber(cs.marginLeft) + getNumber(cs.marginRight);
const inner = padding + border;
const all = inner + margin;
return { padding, border, margin, inner, all };
}

View File

@@ -0,0 +1,82 @@
:host {
display: block;
position: relative;
aspect-ratio: 16 / 9;
width: 100%;
overflow: hidden;
border-radius: var(--wa-border-radius-m);
}
#frame-container {
position: absolute;
top: 0;
left: 0;
width: calc(100% / var(--zoom));
height: calc(100% / var(--zoom));
transform: scale(var(--zoom));
transform-origin: 0 0;
}
#iframe {
width: 100%;
height: 100%;
border: none;
border-radius: inherit;
/* Prevent the iframe from being selected, e.g. by a double click. Doesn't affect selection withing the iframe. */
user-select: none;
-webkit-user-select: none;
}
#controls {
display: flex;
position: absolute;
bottom: 0.5em;
align-items: center;
font-weight: var(--wa-font-weight-semibold);
padding: 0.25em 0.5em;
gap: 0.5em;
border-radius: var(--wa-border-radius-s);
background: #000b;
color: white;
font-size: min(12px, 0.75em);
user-select: none;
-webkit-user-select: none;
&:dir(ltr) {
right: 0.5em;
}
&:dir(rtl) {
left: 0.5em;
}
button {
display: flex;
align-items: center;
padding: 0.25em;
border: none;
background: none;
color: inherit;
cursor: pointer;
&:focus {
outline: none;
}
&:focus-visible {
outline: var(--wa-focus-ring);
outline-offset: var(--wa-focus-ring-offset);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
span {
min-width: 4.5ch; /* extra space so numbers don't shift */
font-variant-numeric: tabular-nums;
text-align: center;
}
}

View File

@@ -0,0 +1,9 @@
import { expect, fixture, html } from '@open-wc/testing';
describe('<wa-zoomable-frame>', () => {
it('should render a component', async () => {
const el = await fixture(html` <wa-zoomable-frame></wa-zoomable-frame> `);
expect(el).to.exist;
});
});

View File

@@ -0,0 +1,251 @@
import type { PropertyValues } from 'lit';
import { html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { parseSpaceDelimitedTokens } from '../../internal/parse.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import { LocalizeController } from '../../utilities/localize.js';
import styles from './zoomable-frame.css';
/**
* @summary Zoomable frames render iframe content with zoom and interaction controls.
* @documentation https://backers.webawesome.com/docs/components/zoomable-frame
* @status stable
* @since 3.0
*
* @dependency wa-icon
*
* @slot zoom-in-icon - The slot that contains the zoom in icon.
* @slot zoom-out-icon - The slot that contains the zoom out icon.
*
* @event load - Emitted when the internal iframe when it finishes loading.
* @event error - Emitted from the internal iframe when it fails to load.
*
* @csspart iframe - The internal `<iframe>` element.
* @csspart controls - The container that surrounds zoom control buttons.
* @csspart zoom-in-button - The zoom in button.
* @csspart zoom-out-button - The zoom out button.
*
* @cssproperty [--aspect-ratio=16/9] - The aspect ratio of the frame.
*/
@customElement('wa-zoomable-frame')
export default class WaZoomableFrame extends WebAwesomeElement {
static css = styles;
private readonly localize = new LocalizeController(this);
private availableZoomLevels: number[] = [];
@query('#iframe') iframe: HTMLIFrameElement;
/** The URL of the content to display. */
@property() src: string;
/** Inline HTML to display. */
@property() srcdoc: string;
/** Allows fullscreen mode. */
@property({ type: Boolean }) allowfullscreen = false;
/** Controls iframe loading behavior. */
@property() loading: 'eager' | 'lazy' = 'eager';
/** Controls referrer information. */
@property() referrerpolicy: string;
/** Security restrictions for the iframe. */
@property() sandbox: string;
/** The current zoom of the frame, e.g. 0 = 0% and 1 = 100%. */
@property({ type: Number, reflect: true }) zoom = 1;
/**
* The zoom levels to step through when using zoom controls. This does not restrict programmatic changes to the zoom.
*/
@property({ attribute: 'zoom-levels' }) zoomLevels = '25% 50% 75% 100% 125% 150% 175% 200%';
/** Removes the zoom controls. */
@property({ type: Boolean, attribute: 'without-controls', reflect: true }) withoutControls = false;
/** Disables interaction when present. */
@property({ type: Boolean, attribute: 'without-interaction', reflect: true }) withoutInteraction = false;
/** Returns the internal iframe's `window` object. (Readonly property) */
public get contentWindow(): Window | null {
return this.iframe?.contentWindow || null;
}
/** Returns the internal iframe's `document` object. (Readonly property) */
public get contentDocument(): Document | null {
return this.iframe?.contentDocument || null;
}
private parseZoomLevels(zoomLevelsString: string): number[] {
const tokens = parseSpaceDelimitedTokens(zoomLevelsString);
const levels: number[] = [];
for (const token of tokens) {
let value: number;
if (token.endsWith('%')) {
// Parse percentage and convert to 0-1 scale
const percentage = parseFloat(token.slice(0, -1));
if (!isNaN(percentage)) {
value = Math.max(0, percentage / 100); // Min 0, no max
} else {
continue; // Skip invalid values
}
} else {
// Parse as number (0-1 scale)
value = parseFloat(token);
if (!isNaN(value)) {
value = Math.max(0, value); // Min 0, no max
} else {
continue; // Skip invalid values
}
}
levels.push(value);
}
// Sort levels and remove duplicates
return [...new Set(levels)].sort((a, b) => a - b);
}
private getCurrentZoomIndex(): number {
if (this.availableZoomLevels.length === 0) return -1;
// Find the closest zoom level index
let closestIndex = 0;
let closestDiff = Math.abs(this.availableZoomLevels[0] - this.zoom);
for (let i = 1; i < this.availableZoomLevels.length; i++) {
const diff = Math.abs(this.availableZoomLevels[i] - this.zoom);
if (diff < closestDiff) {
closestDiff = diff;
closestIndex = i;
}
}
return closestIndex;
}
private isZoomInDisabled(): boolean {
if (this.availableZoomLevels.length === 0) return false;
const currentIndex = this.getCurrentZoomIndex();
return currentIndex >= this.availableZoomLevels.length - 1;
}
private isZoomOutDisabled(): boolean {
if (this.availableZoomLevels.length === 0) return false;
const currentIndex = this.getCurrentZoomIndex();
return currentIndex <= 0;
}
updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has('zoom')) {
this.style.setProperty('--zoom', `${this.zoom}`);
}
if (changedProperties.has('zoomLevels')) {
this.availableZoomLevels = this.parseZoomLevels(this.zoomLevels);
// If current zoom is not in the available levels, snap to the closest one
if (this.availableZoomLevels.length > 0) {
const currentIndex = this.getCurrentZoomIndex();
if (Math.abs(this.availableZoomLevels[currentIndex] - this.zoom) > 0.001) {
this.zoom = this.availableZoomLevels[currentIndex];
}
}
}
}
/** Zooms in to the next available zoom level. */
public zoomIn() {
if (this.availableZoomLevels.length === 0) {
// Fallback to original behavior if no zoom levels defined
this.zoom = Math.min(this.zoom + 0.05, 2);
return;
}
const currentIndex = this.getCurrentZoomIndex();
if (currentIndex < this.availableZoomLevels.length - 1) {
this.zoom = this.availableZoomLevels[currentIndex + 1];
}
}
/** Zooms out to the previous available zoom level. */
public zoomOut() {
if (this.availableZoomLevels.length === 0) {
// Fallback to original behavior if no zoom levels defined
this.zoom = Math.max(this.zoom - 0.05, 0);
return;
}
const currentIndex = this.getCurrentZoomIndex();
if (currentIndex > 0) {
this.zoom = this.availableZoomLevels[currentIndex - 1];
}
}
private handleLoad() {
this.dispatchEvent(new Event('load', { bubbles: false, cancelable: false, composed: true }));
}
private handleError() {
this.dispatchEvent(new Event('error', { bubbles: false, cancelable: false, composed: true }));
}
render() {
return html`
<div id="frame-container">
<iframe
id="iframe"
part="iframe"
?inert=${this.withoutInteraction}
?allowfullscreen=${this.allowfullscreen}
loading=${this.loading}
referrerpolicy=${this.referrerpolicy}
sandbox=${ifDefined((this.sandbox as any) ?? undefined)}
src=${ifDefined(this.src ?? undefined)}
srcdoc=${ifDefined(this.srcdoc ?? undefined)}
@load=${this.handleLoad}
@error=${this.handleError}
></iframe>
</div>
${!this.withoutControls
? html`
<div id="controls" part="controls">
<button
part="zoom-out-button"
aria-label=${this.localize.term('zoomOut')}
@click=${this.zoomOut}
?disabled=${this.isZoomOutDisabled()}
>
<slot name="zoom-out-icon">
<wa-icon name="minus" label="Zoom out"></wa-icon>
</slot>
</button>
<span>${this.localize.number(this.zoom, { style: 'percent', maximumFractionDigits: 1 })}</span>
<button
part="zoom-in-button"
aria-label=${this.localize.term('zoomIn')}
@click=${this.zoomIn}
?disabled=${this.isZoomInDisabled()}
>
<slot name="zoom-in-icon">
<wa-icon name="plus" label="Zoom in"></wa-icon>
</slot>
</button>
</div>
`
: ''}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'wa-zoomable-frame': WaZoomableFrame;
}
}

View File

@@ -51,8 +51,7 @@
table,
ul,
video,
wa-callout,
wa-viewport-demo {
wa-callout {
&:has(+ *) {
margin: 0 0 var(--wa-space-xl) 0;
}

View File

@@ -35,6 +35,8 @@ const translation: Translation = {
showPassword: 'عرض كلمة المرور',
slideNum: slide => `شريحة ${slide}`,
toggleColorFormat: 'تغيير صيغة عرض اللون',
zoomIn: 'تكبير',
zoomOut: 'تصغير',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Zobrazit heslo',
slideNum: slide => `Slide ${slide}`,
toggleColorFormat: 'Přepnout formát barvy',
zoomIn: 'Přiblížit',
zoomOut: 'Oddálit',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Vis adgangskode',
slideNum: slide => `Slide ${slide}`,
toggleColorFormat: 'Skift farveformat',
zoomIn: 'Zoom ind',
zoomOut: 'Zoom ud',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Passwort anzeigen',
slideNum: slide => `Folie ${slide}`,
toggleColorFormat: 'Farbformat umschalten',
zoomIn: 'Hineinzoomen',
zoomOut: 'Herauszoomen',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Show password',
slideNum: slide => `Slide ${slide}`,
toggleColorFormat: 'Toggle color format',
zoomIn: 'Zoom in',
zoomOut: 'Zoom out',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Mostrar contraseña',
slideNum: slide => `Diapositiva ${slide}`,
toggleColorFormat: 'Alternar formato de color',
zoomIn: 'Acercar',
zoomOut: 'Alejar',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'نمایش رمز',
slideNum: slide => `اسلاید ${slide}`,
toggleColorFormat: 'تغییر قالب رنگ',
zoomIn: 'بزرگ‌نمایی',
zoomOut: 'کوچک‌نمایی',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Näytä salasana',
slideNum: slide => `Dia ${slide}`,
toggleColorFormat: 'Vaihda väriformaattia',
zoomIn: 'Lähennä',
zoomOut: 'Loitonna',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Montrer le mot de passe',
slideNum: slide => `Diapositive ${slide}`,
toggleColorFormat: 'Changer le format de couleur',
zoomIn: 'Zoomer',
zoomOut: 'Dézoomer',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'הראה סיסמה',
slideNum: slide => `שקופית ${slide}`,
toggleColorFormat: 'החלף פורמט צבע',
zoomIn: 'התקרב',
zoomOut: 'התרחק',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Pokaži lozinku',
slideNum: slide => `Slajd ${slide}`,
toggleColorFormat: 'Zamijeni format boje',
zoomIn: 'Povećaj',
zoomOut: 'Smanji',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Jelszó megjelenítése',
slideNum: slide => `${slide}. dia`,
toggleColorFormat: 'Színformátum változtatása',
zoomIn: 'Nagyítás',
zoomOut: 'Kicsinyítés',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Tampilkan sandi',
slideNum: slide => `Slide ${slide}`,
toggleColorFormat: 'Beralih format warna',
zoomIn: 'Perbesar',
zoomOut: 'Perkecil',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Mostra password',
slideNum: slide => `Diapositiva ${slide}`,
toggleColorFormat: 'Cambia formato colore',
zoomIn: 'Ingrandire',
zoomOut: 'Rimpicciolire',
};
registerTranslation(translation);

View File

@@ -32,6 +32,8 @@ const translation: Translation = {
showPassword: 'パスワードを表示',
slideNum: slide => `スライド ${slide}`,
toggleColorFormat: '色のフォーマットを切り替える',
zoomIn: 'ズームイン',
zoomOut: 'ズームアウト',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Vis passord',
slideNum: slide => `Visning ${slide}`,
toggleColorFormat: 'Bytt fargeformat',
zoomIn: 'Zoom inn',
zoomOut: 'Zoom ut',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Laat wachtwoord zien',
slideNum: slide => `Schuif ${slide}`,
toggleColorFormat: 'Wissel kleurnotatie',
zoomIn: 'Inzoomen',
zoomOut: 'Uitzoomen',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Vis passord',
slideNum: slide => `Visning ${slide}`,
toggleColorFormat: 'Byt fargeformat',
zoomIn: 'Zoom inn',
zoomOut: 'Zoom ut',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Pokaż hasło',
slideNum: slide => `Slajd ${slide}`,
toggleColorFormat: 'Przełącz format',
zoomIn: 'Powiększ',
zoomOut: 'Pomniejsz',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Mostrar senha',
slideNum: slide => `Slide ${slide}`,
toggleColorFormat: 'Trocar o formato de cor',
zoomIn: 'Aumentar zoom',
zoomOut: 'Diminuir zoom',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Показать пароль',
slideNum: slide => `Слайд ${slide}`,
toggleColorFormat: 'Переключить цветовую модель',
zoomIn: 'Увеличить',
zoomOut: 'Уменьшить',
};
registerTranslation(translation);

View File

@@ -35,6 +35,8 @@ const translation: Translation = {
showPassword: 'Prikaži geslo',
slideNum: slide => `Diapozitiv ${slide}`,
toggleColorFormat: 'Preklopi format barve',
zoomIn: 'Povečaj',
zoomOut: 'Pomanjšaj',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Visa lösenord',
slideNum: slide => `Bild ${slide}`,
toggleColorFormat: 'Växla färgformat',
zoomIn: 'Zooma in',
zoomOut: 'Zooma ut',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: 'Şifreyi göster',
slideNum: slide => `Slayt ${slide}`,
toggleColorFormat: 'Renk biçimini değiştir',
zoomIn: 'Yakınlaştır',
zoomOut: 'Uzaklaştır',
};
registerTranslation(translation);

View File

@@ -35,6 +35,8 @@ const translation: Translation = {
showPassword: 'Показати пароль',
slideNum: slide => `Слайд ${slide}`,
toggleColorFormat: 'Переключити кольорову модель',
zoomIn: 'Збільшити',
zoomOut: 'Зменшити',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: '显示密码',
slideNum: slide => `幻灯片 ${slide}`,
toggleColorFormat: '切换颜色模式',
zoomIn: '放大',
zoomOut: '缩小',
};
registerTranslation(translation);

View File

@@ -33,6 +33,8 @@ const translation: Translation = {
showPassword: '顯示密碼',
slideNum: slide => `幻燈片 ${slide}`,
toggleColorFormat: '切換顏色格式',
zoomIn: '放大',
zoomOut: '縮小',
};
registerTranslation(translation);

View File

@@ -4,10 +4,10 @@ import en from '../translations/en.js'; // Register English as the default/fallb
// Extend the controller and apply our own translation interface for better typings
export class LocalizeController extends DefaultLocalizationController<Translation> {
// Technicallly '../translations/en.js' is supposed to work via side-effects. However, by some mystery sometimes the
// translations don't get bundled as expected resulting in `no translation found` errors.
// This is basically some extra assurance that our translations get registered prior to our localizer connecting in a component
// and we don't rely on implicit import ordering.
// Technically '../translations/en.js' is supposed to work via side-effects. However, by some mystery sometimes the
// translations don't get bundled as expected resulting in `no translation found` errors. This is basically some extra
// assurance that our translations get registered prior to our localizer connecting in a component and we don't rely
// on implicit import ordering.
static {
registerTranslation(en);
}
@@ -44,4 +44,6 @@ export interface Translation extends DefaultTranslation {
showPassword: string;
slideNum: (slide: number) => string;
toggleColorFormat: string;
zoomIn: string;
zoomOut: string;
}