Compare commits

...

4 Commits

Author SHA1 Message Date
Cory LaViska
d110fb4cf0 Merge branch 'next' into docs-fix 2025-06-06 08:13:29 -04:00
Cory LaViska
9a49a76cce update jsdoc 2025-06-06 08:13:17 -04:00
Cory LaViska
c162983ca2 add hover queries (#1035) 2025-06-05 22:03:51 +00:00
Cory LaViska
0d09037916 Zoomable frame (#1029)
* add zoomable frame component; #986

* remove viewport demo component

* update changelog

* fix code demos

* update zoomable iframes with theme changes
2025-06-05 17:34:25 -04:00
61 changed files with 655 additions and 723 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

@@ -116,10 +116,12 @@
padding: 0.5rem;
cursor: pointer;
&:hover {
border-left: var(--wa-border-style) var(--wa-panel-border-width) var(--wa-color-neutral-border-quiet) !important; /* TODO - remove after native styles refactor */
background: var(--wa-color-surface-default) !important; /* TODO - remove after native styles refactor */
color: var(--wa-color-text-quiet) !important; /* TODO - remove after native styles refactor */
@media (hover: hover) {
&:hover {
border-left: var(--wa-border-style) var(--wa-panel-border-width) var(--wa-color-neutral-border-quiet) !important; /* TODO - remove after native styles refactor */
background: var(--wa-color-surface-default) !important; /* TODO - remove after native styles refactor */
color: var(--wa-color-text-quiet) !important; /* TODO - remove after native styles refactor */
}
}
&:first-of-type {

View File

@@ -9,8 +9,10 @@ wa-copy-button.copy-button {
border-radius: var(--wa-border-radius-m);
padding: 0.25rem;
&:hover {
color: white;
@media (hover: hover) {
&:hover {
color: white;
}
}
&:focus-visible {

View File

@@ -495,8 +495,10 @@ table.colors {
tbody {
tr {
border: none;
&:hover {
background: transparent;
@media (hover: hover) {
&:hover {
background: transparent;
}
}
}

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,12 +40,14 @@ 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>`
- Added `--tag-max-size` to `<wa-select>` when using `multiple`
- Added support for `data-dialog="open <id>"` to `<wa-dialog>`
- Added support for `data-drawer="open <id>"` to `<wa-drawer>`
- Added `@media (hover: hover)` to component hover styles to prevent sticky hover states
- Fixed a bug in `<wa-radio-group>` that caused radios to uncheck when assigning a numeric value [issue:924]
- Fixed `<wa-button-group>` so dividers properly show between buttons
- Fixed the tooltip position in `<wa-slider>` when using RTL
@@ -60,8 +62,8 @@ During the alpha period, things might break! We take breaking changes very serio
## 3.0.0-alpha.13
- 🚨 BREAKING: Renamed `<image-comparer>` to `<wa-comparison>` and improved compatibility for non-image content
- 🚨 BREAKING: Added slot detection to `<wa-dialog>` and `<wa-drawer>` so you don't need to specify `with-header` and `with-footer`; headers are on by default now, but you can use the `without-header` attribute to turn them off
- 🚨 BREAKING: Renamed the `image` slot to `media` for a more appropriate naming convention
- 🚨 BREAKING: Added slot detection to `<wa-dialog>` and `<wa-drawer>` so you don't need to specify `with-header` and `with-footer`; headers are on by default now, but you can use the `without-header` attribute to turn them off
- 🚨 BREAKING: Renamed the `image` slot to `media` for a more appropriate naming convention
- Added [a theme builder](/docs/themes/edit/) to create your own themes
- Added a new Blog & News pattern category
- Added a new free component: `<wa-scroller>` (#1 of 14 per stretch goals)
@@ -123,7 +125,7 @@ During the alpha period, things might break! We take breaking changes very serio
### Design Tokens
- Added `--wa-color-[hue]` tokens with the "core" color of each scale, regardless of which tint it lives on.
You can find them in the first column of each color palette.
You can find them in the first column of each color palette.
### Themes
@@ -148,20 +150,21 @@ You can find them in the first column of each color palette.
- Fixed an incorrect CSS value in the expand icon
- Fixed a bug that prevented the description from being read by screen readers
#### `<wa-option>`
#### `<wa-option>`
- `label` attribute to override the generated label (useful for rich content)
- `defaultLabel` property
- Dropped `getTextLabel()` method (if you need dynamic labels, just set the `label` attribute dynamically)
- Dropped `base` part for easier styling. CSS can now be applied directly to the element itself.
#### `<wa-menu-item>`
#### `<wa-menu-item>`
- `label` attribute to override the generated label (useful for rich content)
- `defaultLabel` property
- Dropped `getTextLabel()` method (if you need dynamic labels, just set the `label` attribute dynamically)
#### `<wa-card>`
- Fixed a bug where child elements did not have correct rounding when headers and footers were absent.
- Re-introduced `--border-color` so that the card itself can have a different border color than its inner borders.
- Fixed a bug that prevented slots from showing automatically without `with-` attributes
@@ -347,12 +350,12 @@ Here's a list of some of the things that have changed since Shoelace v2. For que
- Removed `inline` from `<wa-color-picker>`
- Removed `getFormControls()` since we now use Form Associated Custom Elements and can reliably access Web Awesome Elements via `formElement.elements`.
- Removed `valueAsDate` from `<wa-input>`; use the following to mimic native behaviors:
setter: `waInput.value = new Date().toLocaleDateString()`
getter: `new Date(waInput.value)`
setter: `waInput.value = new Date().toLocaleDateString()`
getter: `new Date(waInput.value)`
- Removed `valueAsNumber` from `<wa-input>`; use the following to mimic native behaviors:
setter: `waInput.value = 5.toString()`
getter: `Number(waInput.value)`
setter: `waInput.value = 5.toString()`
getter: `Number(waInput.value)`
Did we miss something? [Let us know!](https://github.com/shoelace-style/webawesome-alpha/discussions)
Are you coming from Shoelace? [The 2.x changelog can be found here.](https://shoelace.style/resources/changelog/)
Are you coming from Shoelace? [The 2.x changelog can be found here.](https://shoelace.style/resources/changelog/)

View File

@@ -36,8 +36,10 @@ img[aria-hidden='true'] {
transition: opacity var(--wa-transition-normal) var(--wa-transition-easing);
}
:host([play]:hover) .control-box {
opacity: 1;
@media (hover: hover) {
:host([play]:hover) .control-box {
opacity: 1;
}
}
:host([play]:not(:hover)) .control-box {

View File

@@ -26,8 +26,10 @@
transition: color var(--wa-transition-normal) var(--wa-transition-easing);
}
:host(:not(:last-of-type)) .label:hover {
color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover));
@media (hover: hover) {
:host(:not(:last-of-type)) .label:hover {
color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover));
}
}
:host(:not(:last-of-type)) .label:active {

View File

@@ -9,9 +9,11 @@
flex-wrap: wrap;
gap: 1px;
> :hover,
&::slotted(:hover) {
z-index: 1;
@media (hover: hover) {
> :hover,
&::slotted(:hover) {
z-index: 1;
}
}
/* Focus and checked are always on top */

View File

@@ -51,10 +51,12 @@
}
/* Interactive states */
.button:not(.disabled):not(.loading):hover {
background-color: var(--background-color-hover, var(--background-color));
border-color: var(--border-color-hover, var(--border-color, var(--background-color-hover)));
color: var(--text-color-hover, var(--text-color));
@media (hover: hover) {
.button:not(.disabled):not(.loading):hover {
background-color: var(--background-color-hover, var(--background-color));
border-color: var(--border-color-hover, var(--border-color, var(--background-color-hover)));
color: var(--text-color-hover, var(--text-color));
}
}
.button:not(.disabled):not(.loading):active {

View File

@@ -22,7 +22,13 @@
transition: color var(--wa-transition-fast) var(--wa-transition-easing);
}
.button:hover:not([disabled]),
@media (hover: hover) {
.button:hover:not([disabled]) {
background-color: var(--background-color-hover);
color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover));
}
}
.button:focus-visible:not([disabled]) {
background-color: var(--background-color-hover);
color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover));

View File

@@ -63,10 +63,7 @@ export default class WaDialog extends WebAwesomeElement {
@query('.dialog') dialog: HTMLDialogElement;
/**
* Indicates whether or not the dialog is open. You can toggle this attribute to show and hide the dialog, or you can
* use the `show()` and `hide()` methods and this attribute will reflect the dialog's open state.
*/
/** Indicates whether or not the dialog is open. Toggle this attribute to show and hide the dialog. */
@property({ type: Boolean, reflect: true }) open = false;
/**

View File

@@ -68,10 +68,7 @@ export default class WaDrawer extends WebAwesomeElement {
@query('.drawer') drawer: HTMLDialogElement;
/**
* Indicates whether or not the drawer is open. You can toggle this attribute to show and hide the drawer, or you can
* use the `show()` and `hide()` methods and this attribute will reflect the drawer's open state.
*/
/** Indicates whether or not the drawer is open. Toggle this attribute to show and hide the drawer. */
@property({ type: Boolean, reflect: true }) open = false;
/**

View File

@@ -166,8 +166,10 @@ textarea {
transition: var(--wa-transition-normal) color;
cursor: pointer;
&:hover {
color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover));
@media (hover: hover) {
&:hover {
color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover));
}
}
&:active {

View File

@@ -23,9 +23,11 @@
outline: none;
}
:host(:not([disabled], :state(current)):is(:state(hover), :hover)) {
background-color: var(--background-color-hover);
color: var(--text-color-hover);
@media (hover: hover) {
:host(:not([disabled], :state(current)):is(:state(hover), :hover)) {
background-color: var(--background-color-hover);
color: var(--text-color-hover);
}
}
:host(:state(current)),

View File

@@ -141,8 +141,10 @@
border-start-end-radius: 0;
}
:host([appearance='button']:hover:not([disabled], :state(checked))) {
background-color: color-mix(in srgb, var(--wa-color-surface-default) 95%, var(--wa-color-mix-hover));
@media (hover: hover) {
:host([appearance='button']:hover:not([disabled], :state(checked))) {
background-color: color-mix(in srgb, var(--wa-color-surface-default) 95%, var(--wa-color-mix-hover));
}
}
:host([appearance='button']:focus-visible) {

View File

@@ -198,8 +198,10 @@ label:has(select),
outline: none;
}
&:hover {
color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover));
@media (hover: hover) {
&:hover {
color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover));
}
}
&:active {

View File

@@ -25,8 +25,10 @@
}
}
:host(:hover:not([disabled])) .tab {
color: currentColor;
@media (hover: hover) {
:host(:hover:not([disabled])) .tab {
color: currentColor;
}
}
:host(:focus) {

View File

@@ -33,8 +33,10 @@
width: 1em;
}
:host(:hover) > [part='remove-button']::part(base) {
color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover));
@media (hover: hover) {
:host(:hover) > [part='remove-button']::part(base) {
color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover));
}
}
:host(:active) > [part='remove-button']::part(base) {

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;
}
@@ -1174,12 +1173,14 @@
background-color: color-mix(in oklab, var(--wa-color-fill-quiet) 60%, transparent);
}
&:hover {
background-color: var(--wa-color-fill-quiet);
@media (hover: hover) {
&:hover {
background-color: var(--wa-color-fill-quiet);
&,
+ tr {
border-top-color: var(--wa-color-border-normal);
&,
+ tr {
border-top-color: var(--wa-color-border-normal);
}
}
}
}

View File

@@ -195,17 +195,21 @@
margin-inline-end: 0.75em;
}
&:hover {
--box-shadow: 0 0 0 0.5em color-mix(in oklab, var(--border-color), transparent 85%);
&:is(:checked, :indeterminate, :state(checked), :state(indeterminate)) {
--box-shadow: 0 0 0 0.5em color-mix(in oklab, var(--border-color-checked), transparent 85%);
@media (hover: hover) {
&:hover {
--box-shadow: 0 0 0 0.5em color-mix(in oklab, var(--border-color), transparent 85%);
&:is(:checked, :indeterminate, :state(checked), :state(indeterminate)) {
--box-shadow: 0 0 0 0.5em color-mix(in oklab, var(--border-color-checked), transparent 85%);
}
}
}
}
input[type='range']:hover,
wa-slider:hover {
--thumb-shadow: 0 0 0 0.5em color-mix(in oklab, var(--thumb-color), transparent 85%);
@media (hover: hover) {
input[type='range']:hover,
wa-slider:hover {
--thumb-shadow: 0 0 0 0.5em color-mix(in oklab, var(--thumb-color), transparent 85%);
}
}
wa-switch {
@@ -220,8 +224,10 @@
margin-inline-end: 0.75em;
}
&:hover {
--thumb-shadow: 0 0 0 0.5em color-mix(in oklab, var(--border-color), transparent 85%);
@media (hover: hover) {
&:hover {
--thumb-shadow: 0 0 0 0.5em color-mix(in oklab, var(--border-color), transparent 85%);
}
}
&:not(:state(checked))::part(thumb) {

View File

@@ -11,8 +11,10 @@
/* Doesn't apply transform to buttons in dropdowns or button groups.
* For dropdowns, this prevents the dropdown panel from shifting. */
&:not(:where(wa-button-group &, wa-dropdown &, wa-radio-group &)) {
&:hover {
transform: scale(1.02);
@media (hover: hover) {
&:hover {
transform: scale(1.02);
}
}
&:active {
@@ -58,15 +60,17 @@
}
&:not([disabled]) {
&:hover {
&:where(:not(wa-button)),
&::part(base) {
background: linear-gradient(
180deg,
var(--gradient-bottom) 0%,
var(--gradient-middle) 51.88%,
var(--gradient-top) 100%
);
@media (hover: hover) {
&:hover {
&:where(:not(wa-button)),
&::part(base) {
background: linear-gradient(
180deg,
var(--gradient-bottom) 0%,
var(--gradient-middle) 51.88%,
var(--gradient-top) 100%
);
}
}
}

View File

@@ -106,10 +106,12 @@
text-decoration: var(--wa-link-decoration-default);
-webkit-text-decoration: var(--wa-link-decoration-default);
&:hover {
color: color-mix(in oklab, var(--wa-color-text-link) 100%, var(--wa-color-mix-hover));
text-decoration: var(--wa-link-decoration-hover);
-webkit-text-decoration: var(--wa-link-decoration-hover);
@media (hover: hover) {
&:hover {
color: color-mix(in oklab, var(--wa-color-text-link) 100%, var(--wa-color-mix-hover));
text-decoration: var(--wa-link-decoration-hover);
-webkit-text-decoration: var(--wa-link-decoration-hover);
}
}
}
@@ -117,9 +119,11 @@
color: var(--wa-color-text-normal);
text-decoration: none;
&:hover {
color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover));
text-decoration: none;
@media (hover: hover) {
&:hover {
color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover));
text-decoration: none;
}
}
}
}

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;
}