Compare commits

..

2 Commits

Author SHA1 Message Date
Cory LaViska
7131213d61 rename var 2025-06-04 16:28:25 -04:00
Cory LaViska
a78e63c821 add data- invokers to dialog and drawer 2025-06-04 16:15:24 -04:00
144 changed files with 4445 additions and 4545 deletions

View File

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

View File

@@ -1,4 +0,0 @@
_site
dist
dist-cdn
src/react

View File

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

View File

@@ -121,18 +121,21 @@
<li><a href="/docs/components/dialog/">Dialog</a></li>
<li><a href="/docs/components/divider/">Divider</a></li>
<li><a href="/docs/components/drawer/">Drawer</a></li>
<li>
<a href="/docs/components/dropdown">Dropdown</a>
<ul>
<li><a href="/docs/components/dropdown-item">Dropdown Item</a></li>
</ul>
</li>
<li><a href="/docs/components/dropdown/">Dropdown</a></li>
<li><a href="/docs/components/format-bytes/">Format Bytes</a></li>
<li><a href="/docs/components/format-date/">Format Date</a></li>
<li><a href="/docs/components/format-number/">Format Number</a></li>
<li><a href="/docs/components/icon/">Icon</a></li>
<li><a href="/docs/components/icon-button/">Icon Button</a></li>
<li><a href="/docs/components/include/">Include</a></li>
<li><a href="/docs/components/input/">Input</a></li>
<li>
<a href="/docs/components/menu/">Menu</a>
<ul>
<li><a href="/docs/components/menu-item/">Menu Item</a></li>
<li><a href="/docs/components/menu-label/">Menu Label</a></li>
</ul>
</li>
<li><a href="/docs/components/mutation-observer/">Mutation Observer</a></li>
<li><a href="/docs/components/popover/">Popover</a></li>
<li><a href="/docs/components/popup/">Popup</a></li>
@@ -172,7 +175,6 @@
<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

@@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 6C0 2.68629 2.68629 0 6 0H26C29.3137 0 32 2.68629 32 6V26C32 29.3137 29.3137 32 26 32H6C2.68629 32 0 29.3137 0 26V6Z" fill="var(--wa-color-neutral-fill-normal)"/>
<path d="M23.4688 13.2188C23.5938 13.5 23.5 13.7812 23.2812 14L21.9375 15.2188C21.9688 15.4688 21.9688 15.75 21.9688 16C21.9688 16.2812 21.9688 16.5625 21.9375 16.8125L23.2812 18.0312C23.5 18.2188 23.5938 18.5312 23.4688 18.8125C23.3438 19.1875 23.1875 19.5312 23 19.875L22.8438 20.125C22.625 20.4688 22.4062 20.8125 22.1562 21.0938C21.9688 21.3438 21.6562 21.4062 21.375 21.3125L19.6562 20.7812C19.2188 21.0938 18.75 21.3438 18.2812 21.5625L17.875 23.3438C17.8125 23.625 17.5938 23.8438 17.3125 23.9062C16.875 23.9688 16.4375 24 15.9688 24C15.5312 24 15.0938 23.9688 14.6562 23.9062C14.375 23.8438 14.1562 23.625 14.0938 23.3438L13.6875 21.5625C13.1875 21.3438 12.75 21.0938 12.3125 20.7812L10.5938 21.3125C10.3125 21.4062 10 21.3438 9.8125 21.125C9.5625 20.8125 9.34375 20.4688 9.125 20.125L8.96875 19.875C8.78125 19.5312 8.625 19.1875 8.5 18.8125C8.375 18.5312 8.46875 18.25 8.6875 18.0312L10.0312 16.8125C10 16.5625 10 16.2812 10 16C10 15.75 10 15.4688 10.0312 15.2188L8.6875 14C8.46875 13.7812 8.375 13.5 8.5 13.2188C8.625 12.8438 8.78125 12.5 8.96875 12.1562L9.125 11.9062C9.34375 11.5625 9.5625 11.2188 9.8125 10.9062C10 10.6875 10.3125 10.625 10.5938 10.7188L12.3125 11.25C12.75 10.9375 13.2188 10.6562 13.6875 10.4688L14.0938 8.6875C14.1562 8.40625 14.375 8.1875 14.6562 8.125C15.0938 8.0625 15.5312 8 16 8C16.4375 8 16.875 8.0625 17.3125 8.125C17.5938 8.15625 17.8125 8.40625 17.875 8.6875L18.2812 10.4688C18.7812 10.6562 19.2188 10.9375 19.6562 11.25L21.375 10.7188C21.6562 10.625 21.9688 10.6875 22.1562 10.9062C22.4062 11.2188 22.625 11.5625 22.8438 11.9062L23 12.1562C23.1875 12.5 23.3438 12.8438 23.5 13.2188H23.4688ZM16 18.5C16.875 18.5 17.6875 18.0312 18.1562 17.25C18.5938 16.5 18.5938 15.5312 18.1562 14.75C17.6875 14 16.875 13.5 16 13.5C15.0938 13.5 14.2812 14 13.8125 14.75C13.375 15.5312 13.375 16.5 13.8125 17.25C14.2812 18.0312 15.0938 18.5 16 18.5Z" fill="var(--wa-color-neutral-on-normal)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -3,9 +3,7 @@
<wa-card>
<div slot="header" class="wa-split">
<h3 class="wa-heading-m">Your Cart</h3>
<wa-button appearance="plain" size="small" tabindex="-1">
<wa-icon name="xmark" label="Close"></wa-icon>
</wa-button>
<wa-icon-button name="xmark" tabindex="-1"></wa-icon-button>
</div>
<div class="wa-stack wa-gap-xl">
<div class="wa-flank">
@@ -75,9 +73,7 @@
<div class="wa-stack">
<div class="wa-split">
<h3 class="wa-heading-m">To-Do</h3>
<wa-button appearance="plain" size="small" tabindex="-1">
<wa-icon name="plus" label="Add task"></wa-icon>
</wa-button>
<wa-icon-button tabindex="-1" name="plus" label="Add task"></wa-icon-button>
</div>
<wa-checkbox tabindex="-1" checked>Umbrella for Adelard</wa-checkbox>
<wa-checkbox tabindex="-1" checked>Waste-paper basket for Dora</wa-checkbox>
@@ -102,9 +98,7 @@
</div>
<span class="wa-caption-m">Samwise G</span>
</div>
<wa-button appearance="plain" size="small" tabindex="-1">
<wa-icon name="ellipsis" label="Options"></wa-icon>
</wa-button>
<wa-icon-button tabindex="-1" name="ellipsis" label="Options"></wa-icon-button>
</div>
<div class="wa-stack wa-gap-2xs">
<wa-progress-bar value="34" style="height: 0.5em"></wa-progress-bar>
@@ -114,15 +108,9 @@
</div>
</div>
<div class="wa-grid wa-align-items-center" style="--min-column-size: 1em; justify-items: center;">
<wa-button appearance="plain" tabindex="-1">
<wa-icon name="backward" label="Skip backward"></wa-icon>
</wa-button>
<wa-button appearance="plain" size="large" tabindex="-1">
<wa-icon name="pause" label="Pause"></wa-icon>
</wa-button>
<wa-button appearance="plain" tabindex="-1">
<wa-icon name="forward" label="Skip forward"></wa-icon>
</wa-button>
<wa-icon-button tabindex="-1" name="backward" label="Skip backward"></wa-icon-button>
<wa-icon-button tabindex="-1" name="pause" style="font-size: var(--wa-font-size-2xl);" label="Pause"></wa-icon-button>
<wa-icon-button tabindex="-1" name="forward" label="Skip forward"></wa-icon-button>
</div>
</div>
</wa-card>
@@ -265,9 +253,7 @@
</div>
</a>
<wa-dropdown>
<wa-button id="more-actions-2" slot="trigger" appearance="plain" size="small" tabindex="-1">
<wa-icon name="ellipsis-vertical" label="View menu"></wa-icon>
</wa-button>
<wa-icon-button id="more-actions-2" slot="trigger" name="ellipsis-vertical" label="View menu" tabindex="-1"></wa-icon-button>
<wa-menu>
<wa-menu-item>Copy link</wa-menu-item>
<wa-menu-item>Rename</wa-menu-item>

View File

@@ -70,9 +70,7 @@
<div class="alignment">
<wa-avatar></wa-avatar>
<wa-rating></wa-rating>
<wa-button appearance="plain">
<wa-icon name="gear" label="Settings"></wa-icon>
</wa-button>
<wa-icon-button name="gear" label="Settings"></wa-icon-button>
<wa-spinner></wa-spinner>
</div>
</div>

View File

@@ -33,14 +33,14 @@
<h1 class="title">
<span v-content="title">{{ title }}</span>
<template v-if="saved || tweaked">
<wa-button appearance="plain" size="small" @click="rename">
<wa-icon name="pencil" label="Rename palette"></wa-icon>
</wa-button>
<wa-button appearance="plain" size="small" v-if="saved" class="delete" @click="deleteSaved">
<wa-icon name="trash" label="Delete palette"></wa-icon>
</wa-button>
<wa-icon-button name="pencil" label="Rename palette" @click="rename"></wa-icon-button>
<wa-icon-button v-if="saved" class="delete" name="trash" label="Delete palette" @click="deleteSaved"></wa-icon-button>
<wa-button @click="save()" :disabled="!unsavedChanges"
:variant="unsavedChanges ? 'success' : 'neutral'" size="small" :appearance="unsavedChanges ? 'accent' : 'outlined'">
<span slot="prefix" class="icon-modifier">
<wa-icon name="sidebar" variant="regular"></wa-icon>
<wa-icon name="circle-plus" class="modifier" style="color: light-dark(var(--wa-color-green-70), var(--wa-color-green-60));"></wa-icon>
</span>
<span v-content="unsavedChanges ? 'Save' : 'Saved'">Save</span>
</wa-button>
</template>
@@ -71,8 +71,10 @@
<wa-tag v-for="tweakHumanReadable, param in tweaksHumanReadable" with-remove @wa-remove="reset(param)" v-content="tweakHumanReadable"></wa-tag>
</div>
<wa-button @click="reset()" appearance="outlined" variant="danger" size="small">
<wa-icon slot="prefix" name="circle-xmark" variant="regular"></wa-icon>
<wa-button @click="reset()" appearance="outlined" variant="danger">
<span slot="prefix" class="icon-modifier">
<wa-icon name="circle-xmark" variant="regular"></wa-icon>
</span>
Reset
</wa-button>
</wa-callout>
@@ -128,9 +130,7 @@
@input="tweaking.grayChroma = true" @change="tweaking.grayChroma = false">
<div slot="label">
Gray colorfulness
<wa-button appearance="plain" @click="grayChroma = originalGrayChroma" class="clear-button">
<wa-icon name="circle-xmark" label="Reset" variant="regular"></wa-icon>
</wa-button>
<wa-icon-button @click="grayChroma = originalGrayChroma" class="clear-button" name="circle-xmark" library="system" variant="regular" label="Reset"></wa-icon-button>
</div>
</wa-slider>
<div class="label-min">Neutral</div>
@@ -149,9 +149,7 @@
@change="tweaking.hue = tweaking.{{ hue }} = false">
<div slot="label">
Tweak {{ hue }} hue
<wa-button appearance="plain" @click="hueShifts.{{ hue }} = 0" class="clear-button">
<wa-icon name="circle-xmark" label="Reset" variant="regular"></wa-icon>
</wa-button>
<wa-icon-button @click="hueShifts.{{ hue }} = 0" class="clear-button" name="circle-xmark" library="system" variant="regular" label="Reset"></wa-icon-button>
</div>
</wa-slider>
<div class="label-min">More {{hueBefore}}</div>
@@ -195,9 +193,7 @@ style="--min: {{ chromaScaleBounds[0] }}; --max: {{ chromaScaleBounds[1] }};">
@change="tweaking.chroma = false">
<div slot="label">
Overall colorfulness
<wa-button appearance="plain" @click="chromaScale = 1" class="clear-button">
<wa-icon name="circle-xmark" label="Reset" variant="regular"></wa-icon>
</wa-button>
<wa-icon-button @click="chromaScale = 1" class="clear-button" name="circle-xmark" library="system" variant="regular" label="Reset"></wa-icon-button>
</div>
</wa-slider>
<div class="label-min">More muted</div>

View File

@@ -25,6 +25,7 @@ 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;
@@ -34,10 +35,29 @@ 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' : ''}">
<div class="code-example ${isOpen ? 'open' : ''} ${isViewportDemo ? 'is-viewport-demo' : ''}">
<div class="code-example-preview">
${preview}
${isViewportDemo ? ` <wa-viewport-demo><iframe srcdoc="${escapedHtml}"></iframe></wa-viewport-demo>` : preview}
<div class="code-example-resizer" aria-hidden="true">
<wa-icon name="grip-lines-vertical"></wa-icon>
</div>

View File

@@ -32,7 +32,7 @@ export function copyCodePlugin(eleventyConfig, options = {}) {
}
// Add a copy button
pre.innerHTML += `<wa-button href="#${preId}" class="block-link-icon" appearance="plain" size="small"><wa-icon name="link" label="Copy link"></wa-icon></wa-button>
pre.innerHTML += `<wa-icon-button href="#${preId}" class="block-link-icon" name="link"></wa-icon-button>
<wa-copy-button from="${codeId}" class="copy-button"></wa-copy-button>`;
});

View File

@@ -32,9 +32,7 @@
</header>
<nav slot="subheader">
<div class="wa-cluster" style="flex-wrap: nowrap">
<wa-button data-toggle-nav appearance="plain" size="small">
<wa-icon name="bars" label="Menu"></wa-icon>
</wa-button>
<wa-icon-button data-toggle-nav name="bars" label="Menu"></wa-icon-button>
<wa-breadcrumb style="font-size: var(--wa-font-size-s)">
<wa-breadcrumb-item>Field Guides</wa-breadcrumb-item>
<wa-breadcrumb-item>Owls</wa-breadcrumb-item>

View File

@@ -12,9 +12,7 @@
<wa-page class="wa-dark">
<header slot="header">
<div class="wa-cluster">
<wa-button data-toggle-nav appearance="plain" size="small">
<wa-icon name="bars" label="Menu"></wa-icon>
</wa-button>
<wa-icon-button name="bars" label="Menu" data-toggle-nav></wa-icon-button>
<wa-icon name="record-vinyl"></wa-icon>
<span class="wa-heading-m">radiogaga</span>
</div>
@@ -32,10 +30,7 @@
</wa-input>
<div class="wa-split">
<h2 class="wa-heading-s">For You</h2>
<wa-button id="settings" appearance="plain" size="small">
<wa-icon name="gear" label="Settings"></wa-icon>
</wa-button>
<wa-tooltip for="settings">Settings</wa-tooltip>
<wa-icon-button id="settings" name="gear" label="Settings"></wa-icon-button>
</div>
</div>
<nav slot="navigation">
@@ -125,18 +120,12 @@
</ul>
</nav>
<div slot="main-header">
<wa-button id="back" appearance="plain" size="small">
<wa-icon name="chevron-left" label="Back"></wa-icon>
</wa-button>
<wa-icon-button id="back" name="chevron-left" label="Back"></wa-icon-button>
<wa-tooltip for="back" placement="bottom" distance="2">Back</wa-tooltip>
<div class="wa-cluster">
<wa-button id="favorite" appearance="plain" size="small">
<wa-icon name="heart" label="Favorite" variant="regular"></wa-icon>
</wa-button>
<wa-icon-button id="favorite" name="heart" variant="regular" label="Favorite"></wa-icon-button>
<wa-tooltip for="favorite" placement="bottom" distance="2">Favorite</wa-tooltip>
<wa-button id="options" appearance="plain" size="small">
<wa-icon name="ellipsis" label="Options"></wa-icon>
</wa-button>
<wa-icon-button id="options" name="ellipsis" label="Options"></wa-icon-button>
<wa-tooltip for="options" placement="bottom" distance="2">Options</wa-tooltip>
</div>
</div>
@@ -163,16 +152,10 @@
</div>
<div id="play-controls" class="wa-split wa-gap-xl">
<div class="wa-cluster wa-gap-xl">
<wa-button variant="brand" size="large">
<wa-icon name="play" label="Play"></wa-icon>
</wa-button>
<wa-button appearance="plain">
<wa-icon name="shuffle" label="Shuffle"></wa-icon>
</wa-button>
<wa-icon-button name="play" label="Play"></wa-icon-button>
<wa-icon-button name="shuffle" label="Shuffle"></wa-icon-button>
</div>
<wa-button appearance="plain">
<wa-icon name="plus" label="Add to Library"></wa-icon>
</wa-button>
<wa-icon-button name="plus" label="Add to Library"></wa-icon-button>
</div>
</div>
</div>
@@ -184,9 +167,7 @@
</span>
<span class="wa-cluster">
<span class="wa-caption-m">3:27</span>
<wa-button appearance="plain" size="small">
<wa-icon name="ellipsis" label="Song Options"></wa-icon>
</wa-button>
<wa-icon-button name="ellipsis" label="Song Options"></wa-icon-button>
</span>
</li>
<li class="wa-split">
@@ -196,9 +177,7 @@
</span>
<span class="wa-cluster">
<span class="wa-caption-m">2:36</span>
<wa-button appearance="plain" size="small">
<wa-icon name="ellipsis" label="Song Options"></wa-icon>
</wa-button>
<wa-icon-button name="ellipsis" label="Song Options"></wa-icon-button>
</span>
</li>
<li class="wa-split">
@@ -208,9 +187,7 @@
</span>
<span class="wa-cluster">
<span class="wa-caption-m">2:51</span>
<wa-button appearance="plain" size="small">
<wa-icon name="ellipsis" label="Song Options"></wa-icon>
</wa-button>
<wa-icon-button name="ellipsis" label="Song Options"></wa-icon-button>
</span>
</li>
<li class="wa-split">
@@ -220,9 +197,7 @@
</span>
<span class="wa-cluster">
<span class="wa-caption-m">3:05</span>
<wa-button appearance="plain" size="small">
<wa-icon name="ellipsis" label="Song Options"></wa-icon>
</wa-button>
<wa-icon-button name="ellipsis" label="Song Options"></wa-icon-button>
</span>
</li>
<li class="wa-split">
@@ -232,9 +207,7 @@
</span>
<span class="wa-cluster">
<span class="wa-caption-m">1:56</span>
<wa-button appearance="plain" size="small">
<wa-icon name="ellipsis" label="Song Options"></wa-icon>
</wa-button>
<wa-icon-button name="ellipsis" label="Song Options"></wa-icon-button>
</span>
</li>
<li class="wa-split">
@@ -244,9 +217,7 @@
</span>
<span class="wa-cluster">
<span class="wa-caption-m">3:32</span>
<wa-button appearance="plain" size="small">
<wa-icon name="ellipsis" label="Song Options"></wa-icon>
</wa-button>
<wa-icon-button name="ellipsis" label="Song Options"></wa-icon-button>
</span>
</li>
<li class="wa-split">
@@ -256,9 +227,7 @@
</span>
<span class="wa-cluster">
<span class="wa-caption-m">2:46</span>
<wa-button appearance="plain" size="small">
<wa-icon name="ellipsis" label="Song Options"></wa-icon>
</wa-button>
<wa-icon-button name="ellipsis" label="Song Options"></wa-icon-button>
</span>
</li>
<li class="wa-split">
@@ -268,9 +237,7 @@
</span>
<span class="wa-cluster">
<span class="wa-caption-m">3:27</span>
<wa-button appearance="plain" size="small">
<wa-icon name="ellipsis" label="Song Options"></wa-icon>
</wa-button>
<wa-icon-button name="ellipsis" label="Song Options"></wa-icon-button>
</span>
</li>
<li class="wa-split">
@@ -280,9 +247,7 @@
</span>
<span class="wa-cluster">
<span class="wa-caption-m">2:13</span>
<wa-button appearance="plain" size="small">
<wa-icon name="ellipsis" label="Song Options"></wa-icon>
</wa-button>
<wa-icon-button name="ellipsis" label="Song Options"></wa-icon-button>
</span>
</li>
<li class="wa-split">
@@ -295,9 +260,7 @@
</span>
<span class="wa-cluster">
<span class="wa-caption-m">2:55</span>
<wa-button appearance="plain" size="small">
<wa-icon name="ellipsis" label="Song Options"></wa-icon>
</wa-button>
<wa-icon-button name="ellipsis" label="Song Options"></wa-icon-button>
</span>
</li>
<li class="wa-split">
@@ -310,9 +273,7 @@
</span>
<span class="wa-cluster">
<span class="wa-caption-m">3:10</span>
<wa-button appearance="plain" size="small">
<wa-icon name="ellipsis" label="Song Options"></wa-icon>
</wa-button>
<wa-icon-button name="ellipsis" label="Song Options"></wa-icon-button>
</span>
</li>
<li class="wa-split">
@@ -325,9 +286,7 @@
</span>
<span class="wa-cluster">
<span class="wa-caption-m">3:22</span>
<wa-button appearance="plain" size="small">
<wa-icon name="ellipsis" label="Song Options"></wa-icon>
</wa-button>
<wa-icon-button name="ellipsis" label="Song Options"></wa-icon-button>
</span>
</li>
</ol>
@@ -421,8 +380,8 @@
aspect-ratio: 1;
color: var(--wa-color-brand-fill-loud);
display: flex;
height: var(--flank-size);
justify-content: center;
padding-block: 0.5em;
}
#recent wa-icon {
border-radius: var(--wa-border-radius-s);
@@ -461,14 +420,16 @@
[slot='main-header'] {
background-color: var(--wa-color-surface-raised);
}
#play-controls wa-button::part(base) {
#play-controls wa-icon-button::part(base) {
border: var(--wa-border-width-l) var(--wa-border-style) currentColor;
border-radius: var(--wa-border-radius-circle);
font-size: 1.5rem;
}
#play-controls wa-button:has(wa-icon[name='play'])::part(base) {
#play-controls wa-icon-button[name='play']::part(base) {
background-color: var(--wa-color-brand-fill-loud);
border: none;
font-size: 2.5rem;
color: var(--wa-color-brand-on-loud);
font-size: 3rem;
padding: 0.5em 0.45em 0.5em 0.55em;
}
[slot='main-footer'].wa-grid > * {

View File

@@ -1,4 +1,4 @@
import { domChange, ThemeAspect } from './theme-picker.js';
import { domChange, nextFrame, 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 new Promise(requestAnimationFrame);
await nextFrame();
}
oldStylesheet.remove();

View File

@@ -79,12 +79,10 @@ const sidebar = {
let append = [...badges];
if (entity.delete) {
let deleteButton = Object.assign(document.createElement('wa-button'), {
appearance: 'plain',
variant: 'danger',
size: 'small',
let deleteButton = Object.assign(document.createElement('wa-icon-button'), {
name: 'trash',
label: 'Delete',
className: 'delete',
innerHTML: '<wa-icon name="trash" label="Delete"></wa-icon>',
});
deleteButton.addEventListener('click', () => entity.delete());
append.push(deleteButton);

View File

@@ -1,11 +1,14 @@
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).
@@ -64,30 +67,6 @@ 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);
@@ -108,22 +87,27 @@ const colorScheme = new ThemeAspect({
},
applyChange() {
// Toggle the dark mode class with view transition
const updateTheme = () => {
// Toggle the dark mode class
domChange(() => {
let dark = this.computedValue === 'dark';
document.documentElement.classList.toggle(`wa-dark`, dark);
document.documentElement.dispatchEvent(new CustomEvent('wa-color-scheme-change', { detail: { dark } }));
this.syncIframes();
};
if (document.startViewTransition) {
document.startViewTransition(() => domChange(updateTheme));
} else {
domChange(updateTheme);
}
syncViewportDemoColorSchemes();
});
},
});
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());
@@ -137,3 +121,12 @@ 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,12 +116,10 @@
padding: 0.5rem;
cursor: pointer;
@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 */
}
&: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,10 +9,8 @@ wa-copy-button.copy-button {
border-radius: var(--wa-border-radius-m);
padding: 0.25rem;
@media (hover: hover) {
&:hover {
color: white;
}
&:hover {
color: white;
}
&:focus-visible {

View File

@@ -193,13 +193,10 @@ wa-badge.pro {
}
}
wa-button.delete {
wa-icon-button.delete {
vertical-align: -0.2em;
margin-inline-start: var(--wa-space-xs);
&:hover wa-icon {
color: var(--wa-color-danger-on-quiet);
}
&:not(li:hover > *, :focus) {
opacity: 0;
}
@@ -211,9 +208,12 @@ wa-badge.pro {
}
}
wa-button.delete {
&::part(base):hover {
wa-icon-button.delete {
&:hover {
color: var(--wa-color-danger-on-quiet);
}
&::part(base):hover {
background: var(--wa-color-danger-fill-quiet);
}
@@ -495,10 +495,8 @@ table.colors {
tbody {
tr {
border: none;
@media (hover: hover) {
&:hover {
background: transparent;
}
&:hover {
background: transparent;
}
}
@@ -547,6 +545,27 @@ table.colors {
--icon-color: var(--wa-color-success-fill-quiet);
}
.icon-modifier {
position: relative;
display: inline-flex;
.modifier {
position: absolute;
bottom: -0.1em;
right: -0.3em;
font-size: 60%;
&::part(svg) {
stroke: var(--background-color, var(--wa-color-surface-default));
stroke-width: 100px;
paint-order: stroke;
overflow: visible;
stroke-linecap: round;
stroke-linejoin: round;
}
}
}
/* Layout Examples */
.layout-example-boundary {
border: var(--wa-border-width-s) dashed var(--wa-color-neutral-border-normal);

View File

@@ -124,7 +124,7 @@ html.wa-theme-tailspin .preview-container {
&::part(footer) {
border: none;
}
& wa-button {
& wa-icon-button {
color: var(--wa-color-base-50);
}
}
@@ -226,11 +226,11 @@ html.wa-theme-brutalist .preview-container {
--wa-color-neutral-border-quiet: color-mix(in oklab, var(--wa-color-gray-30), white 40%);
}
.message-composer [slot='header'] wa-button::part(base) {
.message-composer [slot='header'] wa-icon-button::part(base) {
color: var(--wa-color-neutral-on-loud);
}
.message-composer .grouped-buttons wa-button::part(base):hover {
.message-composer .grouped-buttons wa-icon-button::part(base):hover {
background-color: var(--wa-color-neutral-fill-normal);
color: var(--wa-color-text-normal);
}
@@ -421,7 +421,7 @@ html.wa-theme-playful .preview-container {
--wa-color-neutral-fill-quiet: var(--wa-color-gray-90);
}
.message-composer wa-button {
.message-composer wa-icon-button {
color: var(--wa-text-color-normal);
font-size: var(--wa-font-size-l);
}
@@ -662,12 +662,12 @@ html.wa-theme-premium .preview-container {
--padding: var(--wa-space-s) var(--wa-space-xl);
}
.message-composer .grouped-buttons wa-button::part(base) {
.message-composer .grouped-buttons wa-icon-button::part(base) {
block-size: var(--wa-form-control-height-s);
inline-size: var(--wa-form-control-height-s);
justify-content: center;
}
.message-composer .grouped-buttons wa-button::part(base):hover {
.message-composer .grouped-buttons wa-icon-button::part(base):hover {
background-color: var(--wa-color-neutral-fill-normal);
color: var(--wa-color-text-normal);
}

View File

@@ -5,11 +5,10 @@
}
.title {
display: flex;
align-items: center;
gap: var(--wa-space-xs);
wa-icon-button {
font-size: var(--wa-font-size-l);
color: var(--wa-color-text-quiet);
wa-button:has(wa-icon) {
&:not(:hover, :focus) {
opacity: 0.5;
}
@@ -132,7 +131,7 @@
field-sizing: content;
}
wa-button {
wa-icon-button {
font-size: 90%;
}
}

View File

@@ -4,15 +4,11 @@ const template = `
<span class="editable-text">
<template v-if="isEditing">
<input ref="input" class="wa-size-s" :aria-label="label" :value="value" @input="handleInput" @keydown.enter="done" @keydown.esc="cancel" @blur="handleBlur" />
<wa-button appearance="plain" v-if="blur !== 'done'" @click="done">
<wa-icon name="check" label="Done editing"></wa-icon>
</wa-button>
<wa-icon-button v-if="blur !== 'done'" name="check" label="Done editing" @click="done"></wa-icon-button>
</template>
<template v-else>
<span class="text" ref="wrapper" @focus="edit" @click="edit" tabindex="0">{{ value }}</span>
<wa-button appearance="plain" @click="edit">
<wa-icon name="pencil" :label="'Edit ' + label"></wa-icon>
</wa-button>
<wa-icon-button name="pencil" :label="'Edit ' + label" @click="edit"></wa-icon-button>
</template>
</span>
`;

View File

@@ -8,9 +8,7 @@ const template = `
<div class="ui-slider-header">
<label :for="sliderId">{{ label }}</label>
<info-tip v-if="clearable && (value !== defaultValue ?? initialValue)" :text="'Reset to ' + valueFormatter(defaultValue ?? initialValue)">
<wa-button @click="value = defaultValue ?? initialValue" class="clear-button">
<wa-icon name="circle-xmark" library="system" variant="regular" :label="'Reset to ' + tooltipFormatter(defaultValue ?? initialValue)"></wa-icon>
</wa-button>
<wa-icon-button @click="value = defaultValue ?? initialValue" class="clear-button" name="circle-xmark" library="system" variant="regular" :label="'Reset to ' + tooltipFormatter(defaultValue ?? initialValue)"></wa-icon-button>
</info-tip>
</div>
<info-tip v-if="$slots.min" :text="'Set to min (' + valueFormatter(min) + ')'">

View File

@@ -103,19 +103,6 @@ It's often helpful to have a button that works like a link. This is possible by
<wa-button href="/assets/images/logo.svg" download="shoelace.svg">Download</wa-button>
```
### Icon Buttons
When only an [icon](/docs/components/icon) is slotted into the `label` slot, the button becomes an icon button. In this case, it's important to give the icon a label for users with assistive devices. Icon buttons can use any appearance or variant.
```html {.example}
<div class="wa-cluster">
<wa-button variant="neutral" appearance="accent"><wa-icon name="house" label="Home"></wa-icon></wa-button>
<wa-button variant="neutral" appearance="outlined"><wa-icon name="house" label="Home"></wa-icon></wa-button>
<wa-button variant="neutral" appearance="filled"><wa-icon name="house" label="Home"></wa-icon></wa-button>
<wa-button variant="neutral" appearance="plain"><wa-icon name="house" label="Home"></wa-icon></wa-button>
</div>
```
### Setting a Custom Width
As expected, buttons can be given a custom width by setting the `width` CSS property. This is useful for making buttons span the full width of their container on smaller screens.

View File

@@ -57,9 +57,7 @@ If using SSR, you need to also use the `with-header` attribute to add a header t
<wa-card class="card-header">
<div slot="header" class="wa-split">
Header Title
<wa-button appearance="plain">
<wa-icon name="gear" variant="solid" label="Settings"></wa-icon>
</wa-button>
<wa-icon-button name="gear" variant="solid" label="Settings" class="wa-size-m"></wa-icon-button>
</div>
This card has a header. You can put all sorts of things in it!

View File

@@ -135,13 +135,11 @@ By design, a dialog's height will never exceed that of the viewport. As such, di
### Header Actions
The header shows a functional close button by default. You can use the `header-actions` slot to add additional [buttons](/docs/components/button) if needed.
The header shows a functional close button by default. You can use the `header-actions` slot to add additional [icon buttons](/docs/components/icon-button) if needed.
```html {.example}
<wa-dialog label="Dialog" class="dialog-header-actions">
<wa-button class="new-window" slot="header-actions" appearance="plain">
<wa-icon name="arrow-up-right-from-square" variant="solid" label="Open in new window"></wa-icon>
</wa-button>
<wa-icon-button class="new-window" slot="header-actions" name="arrow-up-right-from-square" variant="solid"></wa-icon-button>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
<wa-button slot="footer" variant="brand" data-dialog="close">Close</wa-button>
</wa-dialog>

View File

@@ -193,13 +193,11 @@ By design, a drawer's height will never exceed 100% of its container. As such, d
### Header Actions
The header shows a functional close button by default. You can use the `header-actions` slot to add additional [buttons](/docs/components/button) if needed.
The header shows a functional close button by default. You can use the `header-actions` slot to add additional [icon buttons](/docs/components/icon-button) if needed.
```html {.example}
<wa-drawer label="Drawer" class="drawer-header-actions">
<wa-button class="new-window" slot="header-actions" appearance="plain">
<wa-icon name="arrow-up-right-from-square" variant="solid" label="Open in new window"></wa-icon>
</wa-button>
<wa-icon-button class="new-window" slot="header-actions" name="arrow-up-right-from-square" variant="solid"></wa-icon-button>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
<wa-button slot="footer" variant="brand" data-drawer="close">Close</wa-button>
</wa-drawer>

View File

@@ -1,7 +0,0 @@
---
title: Dropdown Item
description: Description of component.
layout: component
---
This component must be used as a child of `<wa-dropdown>`. Please see the [Dropdown docs](/docs/components/dropdown) to see examples of this component in action.

View File

@@ -7,38 +7,28 @@ icon: dropdown
Dropdowns consist of a trigger and a panel. By default, activating the trigger will expose the panel and interacting outside of the panel will close it.
Dropdowns are designed to work well with [dropdown items](/docs/components/dropdown-item) to provide a list of options the user can select from. However, dropdowns can also be used in lower-level applications. The API gives you complete control over showing, hiding, and positioning the panel.
Dropdowns are designed to work well with [menus](/docs/components/menu) to provide a list of options the user can select from. However, dropdowns can also be used in lower-level applications (e.g. [color picker](/docs/components/color-picker)). The API gives you complete control over showing, hiding, and positioning the panel.
```html {.example}
<wa-dropdown>
<wa-button slot="trigger" caret>Dropdown</wa-button>
<wa-dropdown-item>
<wa-icon slot="icon" name="scissors"></wa-icon>
Cut
</wa-dropdown-item>
<wa-dropdown-item>
<wa-icon slot="icon" name="copy"></wa-icon>
Copy
</wa-dropdown-item>
<wa-dropdown-item>
<wa-icon slot="icon" name="paste"></wa-icon>
Paste
</wa-dropdown-item>
<wa-divider></wa-divider>
<wa-dropdown-item>
Show images
<wa-dropdown-item slot="submenu" value="show-all-images">Show All Images</wa-dropdown-item>
<wa-dropdown-item slot="submenu" value="show-thumbnails">Show Thumbnails</wa-dropdown-item>
</wa-dropdown-item>
<wa-divider></wa-divider>
<wa-dropdown-item type="checkbox">Emoji Shortcuts<wa-dropdown-item>
<wa-dropdown-item type="checkbox">Word Wrap</wa-dropdown-item>
<wa-divider></wa-divider>
<wa-dropdown-item variant="danger">
<wa-icon slot="icon" name="trash"></wa-icon>
Delete
</wa-dropdown-item>
<wa-menu>
<wa-menu-item>Dropdown Item 1</wa-menu-item>
<wa-menu-item>Dropdown Item 2</wa-menu-item>
<wa-menu-item>Dropdown Item 3</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item type="checkbox" checked>Checkbox</wa-menu-item>
<wa-menu-item disabled>Disabled</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item>
Prefix
<wa-icon slot="prefix" name="gift" variant="solid"></wa-icon>
</wa-menu-item>
<wa-menu-item>
Suffix Icon
<wa-icon slot="suffix" name="heart" variant="solid"></wa-icon>
</wa-menu-item>
</wa-menu>
</wa-dropdown>
```
@@ -46,16 +36,17 @@ Dropdowns are designed to work well with [dropdown items](/docs/components/dropd
### Getting the Selected Item
When an item is selected, the `wa-select` event will be emitted by the dropdown. You can inspect `event.detail.item` to get a reference to the selected item. If you've provided a value for each [dropdown item](/docs/components/dropdown-item), it will be available in `event.detail.item.value`.
When dropdowns are used with [menus](/docs/components/menu), you can listen for the [`wa-select`](/docs/components/menu#events) event to determine which menu item was selected. The menu item element will be exposed in `event.detail.item`. You can set `value` props to make it easier to identify commands.
```html {.example}
<div class="dropdown-selection">
<wa-dropdown>
<wa-button slot="trigger" caret>View</wa-button>
<wa-dropdown-item value="full-screen">Enter full screen</wa-dropdown-item>
<wa-dropdown-item value="actual">Actual size</wa-dropdown-item>
<wa-dropdown-item value="zoom-in">Zoom in</wa-dropdown-item>
<wa-dropdown-item value="zoom-out">Zoom out</wa-dropdown-item>
<wa-button slot="trigger" caret>Edit</wa-button>
<wa-menu>
<wa-menu-item value="cut">Cut</wa-menu-item>
<wa-menu-item value="copy">Copy</wa-menu-item>
<wa-menu-item value="paste">Paste</wa-menu-item>
</wa-menu>
</wa-dropdown>
</div>
@@ -64,191 +55,53 @@ When an item is selected, the `wa-select` event will be emitted by the dropdown.
const dropdown = container.querySelector('wa-dropdown');
dropdown.addEventListener('wa-select', event => {
console.log(event.detail.item.value);
const selectedItem = event.detail.item;
console.log(selectedItem.value);
});
</script>
```
:::info
To keep the dropdown open after selection, call `event.preventDefault()` in the `wa-select` event's callback.
:::
### Showing Icons
Use the `icon` slot to add icons to [dropdown items](/docs/components/dropdown-item). This works best with [icon](/docs/components/icon) elements.
Alternatively, you can listen for the `click` event on individual menu items. Note that, using this approach, disabled menu items will still emit a `click` event.
```html {.example}
<wa-dropdown>
<wa-button slot="trigger" caret>Edit</wa-button>
<wa-dropdown-item value="cut">
<wa-icon slot="icon" name="scissors"></wa-icon>
Cut
</wa-dropdown-item>
<wa-dropdown-item value="copy">
<wa-icon slot="icon" name="copy"></wa-icon>
Copy
</wa-dropdown-item>
<wa-dropdown-item value="paste">
<wa-icon slot="icon" name="paste"></wa-icon>
Paste
</wa-dropdown-item>
<wa-dropdown-item value="delete">
<wa-icon slot="icon" name="trash"></wa-icon>
Delete
</wa-dropdown-item>
</wa-dropdown>
```
### Showing Labels & Dividers
Use any heading, e.g. `<h1>``<h6>` to add labels and the [`<wa-divider>`](/docs/components/divider) element for separators.
```html {.example}
<wa-dropdown>
<wa-button slot="trigger" caret>Device</wa-button>
<h3>Type</h3>
<wa-dropdown-item value="phone">Phone</wa-dropdown-item>
<wa-dropdown-item value="tablet">Tablet</wa-dropdown-item>
<wa-dropdown-item value="desktop">Desktop</wa-dropdown-item>
<wa-divider></wa-divider>
<wa-dropdown-item value="more">More options…</wa-dropdown-item>
</wa-dropdown>
```
### Showing Details
Use the `details` slot to display details, such as keyboard shortcuts, inside [dropdown items](/docs/components/dropdown-item).
```html {.example}
<wa-dropdown>
<wa-button slot="trigger" caret>Message</wa-button>
<wa-dropdown-item value="reply">
Reply
<span slot="details">⌘R</span>
</wa-dropdown-item>
<wa-dropdown-item value="forward">
Forward
<span slot="details">⌘F</span>
</wa-dropdown-item>
<wa-dropdown-item value="move">
Move
<span slot="details">⌘M</span>
</wa-dropdown-item>
<wa-divider></wa-divider>
<wa-dropdown-item value="archive">
Archive
<span slot="details">⌘A</span>
</wa-dropdown-item>
<wa-dropdown-item value="delete" variant="danger">
Delete
<span slot="details">Del</span>
</wa-dropdown-item>
</wa-dropdown>
```
### Checkable Items
You can turn a [dropdown item](/docs/components/dropdown-item) into a checkable option by setting `type="checkbox"`. Add the `checked` attribute to make it checked initially. When clicked, the item's checked state will toggle and the dropdown will close. You can cancel the `wa-select` event if you want to keep it open instead.
```html {.example}
<div class="dropdown-checkboxes">
<div class="dropdown-selection-alt">
<wa-dropdown>
<wa-button slot="trigger" caret>View</wa-button>
<wa-dropdown-item type="checkbox" value="canvas" checked>Show canvas</wa-dropdown-item>
<wa-dropdown-item type="checkbox" value="grid" checked>Show grid</wa-dropdown-item>
<wa-dropdown-item type="checkbox" value="source">Show source</wa-dropdown-item>
<wa-divider></wa-divider>
<wa-dropdown-item value="preferences">Preferences…</wa-dropdown-item>
<wa-button slot="trigger" caret>Edit</wa-button>
<wa-menu>
<wa-menu-item value="cut">Cut</wa-menu-item>
<wa-menu-item value="copy">Copy</wa-menu-item>
<wa-menu-item value="paste">Paste</wa-menu-item>
</wa-menu>
</wa-dropdown>
</div>
<script>
const container = document.querySelector('.dropdown-checkboxes');
const dropdown = container.querySelector('wa-dropdown');
const container = document.querySelector('.dropdown-selection-alt');
const cut = container.querySelector('wa-menu-item[value="cut"]');
const copy = container.querySelector('wa-menu-item[value="copy"]');
const paste = container.querySelector('wa-menu-item[value="paste"]');
dropdown.addEventListener('wa-select', event => {
if (event.detail.item.type === 'checkbox') {
// Checkbox
console.log(event.detail.item.value, event.detail.item.checked ? 'checked' : 'unchecked');
} else {
// Not a checkbox
console.log(event.detail.item.value);
}
});
cut.addEventListener('click', () => console.log('cut'));
copy.addEventListener('click', () => console.log('copy'));
paste.addEventListener('click', () => console.log('paste'));
</script>
```
:::info
When a checkable option exists anywhere in the dropdown, all items will receive additional padding so they align properly.
:::
### Destructive Items
Add `variant="danger"` to any [dropdown item](/docs/components/dropdown-item) to highlight that it's a dangerous action.
```html {.example}
<wa-dropdown>
<wa-button slot="trigger" caret>Project</wa-button>
<wa-dropdown-item value="share">
<wa-icon slot="icon" name="share"></wa-icon>
Share
</wa-dropdown-item>
<wa-dropdown-item value="preferences">
<wa-icon slot="icon" name="gear"></wa-icon>
Preferences
</wa-dropdown-item>
<wa-divider></wa-divider>
<h3>Danger zone</h3>
<wa-dropdown-item value="archive">
<wa-icon slot="icon" name="archive"></wa-icon>
Archive
</wa-dropdown-item>
<wa-dropdown-item value="delete" variant="danger">
<wa-icon slot="icon" name="trash"></wa-icon>
Delete
</wa-dropdown-item>
</wa-dropdown>
```
### Placement
The preferred placement of the dropdown can be set with the `placement` attribute. Note that the actual position may vary to ensure the panel remains in the viewport.
```html {.example}
<wa-dropdown placement="right-start">
<wa-button slot="trigger">
File formats
<wa-icon slot="suffix" name="chevron-right"></wa-icon>
</wa-button>
<wa-dropdown-item value="pdf">PDF Document</wa-dropdown-item>
<wa-dropdown-item value="docx">Word Document</wa-dropdown-item>
<wa-dropdown-item value="xlsx">Excel Spreadsheet</wa-dropdown-item>
<wa-dropdown-item value="pptx">PowerPoint Presentation</wa-dropdown-item>
<wa-dropdown-item value="txt">Plain Text</wa-dropdown-item>
<wa-dropdown-item value="json">JSON File</wa-dropdown-item>
<wa-dropdown placement="top-start">
<wa-button slot="trigger" caret>Edit</wa-button>
<wa-menu>
<wa-menu-item>Cut</wa-menu-item>
<wa-menu-item>Copy</wa-menu-item>
<wa-menu-item>Paste</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item>Find</wa-menu-item>
<wa-menu-item>Replace</wa-menu-item>
</wa-menu>
</wa-dropdown>
```
@@ -259,111 +112,71 @@ The distance from the panel to the trigger can be customized using the `distance
```html {.example}
<wa-dropdown distance="30">
<wa-button slot="trigger" caret>Edit</wa-button>
<wa-dropdown-item>Cut</wa-dropdown-item>
<wa-dropdown-item>Copy</wa-dropdown-item>
<wa-dropdown-item>Paste</wa-dropdown-item>
<wa-divider></wa-divider>
<wa-dropdown-item>Find</wa-dropdown-item>
<wa-dropdown-item>Replace</wa-dropdown-item>
<wa-menu>
<wa-menu-item>Cut</wa-menu-item>
<wa-menu-item>Copy</wa-menu-item>
<wa-menu-item>Paste</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item>Find</wa-menu-item>
<wa-menu-item>Replace</wa-menu-item>
</wa-menu>
</wa-dropdown>
```
### Offset
### Skidding
The offset of the panel along the trigger can be customized using the `offset` attribute. This value is specified in pixels.
The offset of the panel along the trigger can be customized using the `skidding` attribute. This value is specified in pixels.
```html {.example}
<wa-dropdown offset="30">
<wa-dropdown skidding="30">
<wa-button slot="trigger" caret>Edit</wa-button>
<wa-dropdown-item>Cut</wa-dropdown-item>
<wa-dropdown-item>Copy</wa-dropdown-item>
<wa-dropdown-item>Paste</wa-dropdown-item>
<wa-divider></wa-divider>
<wa-dropdown-item>Find</wa-dropdown-item>
<wa-dropdown-item>Replace</wa-dropdown-item>
<wa-menu>
<wa-menu-item>Cut</wa-menu-item>
<wa-menu-item>Copy</wa-menu-item>
<wa-menu-item>Paste</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item>Find</wa-menu-item>
<wa-menu-item>Replace</wa-menu-item>
</wa-menu>
</wa-dropdown>
```
### Submenus
To create submenus, nest [dropdown items](/docs/components/dropdown-item) inside of a dropdown item and assign `slot="submenu"` to each one. You can also add [dividers](/docs/components/divider) as needed.
To create a submenu, nest an `<wa-menu slot="submenu">` element in a [menu item](/docs/components/menu-item).
```html {.example}
<div class="dropdown-submenus">
<wa-dropdown>
<wa-button slot="trigger" caret>Export</wa-button>
<wa-dropdown-item>
Documents
<wa-dropdown-item slot="submenu" value="pdf">PDF</wa-dropdown-item>
<wa-dropdown-item slot="submenu" value="docx">Word Document</wa-dropdown-item>
</wa-dropdown-item>
<wa-dropdown-item>
Spreadsheets
<wa-dropdown-item slot="submenu">
Excel Formats
<wa-dropdown-item slot="submenu" value="xlsx">Excel (.xlsx)</wa-dropdown-item>
<wa-dropdown-item slot="submenu" value="xls">Excel 97-2003 (.xls)</wa-dropdown-item>
<wa-dropdown-item slot="submenu" value="csv">CSV (.csv)</wa-dropdown-item>
</wa-dropdown-item>
<wa-dropdown-item slot="submenu">
Other Formats
<wa-dropdown-item slot="submenu" value="ods">OpenDocument (.ods)</wa-dropdown-item>
<wa-dropdown-item slot="submenu" value="tsv">Tab-separated (.tsv)</wa-dropdown-item>
<wa-dropdown-item slot="submenu" value="json">JSON (.json)</wa-dropdown-item>
</wa-dropdown-item>
<wa-dropdown-item slot="submenu" value="numbers">Apple Numbers</wa-dropdown-item>
</wa-dropdown-item>
<wa-dropdown>
<wa-button slot="trigger" caret>Edit</wa-button>
<wa-menu style="max-width: 200px;">
<wa-menu-item value="undo">Undo</wa-menu-item>
<wa-menu-item value="redo">Redo</wa-menu-item>
<wa-divider></wa-divider>
<wa-dropdown-item>
Options
<wa-dropdown-item slot="submenu" type="checkbox" value="compress">Compress files</wa-dropdown-item>
<wa-dropdown-item slot="submenu" type="checkbox" checked value="metadata">Include metadata</wa-dropdown-item>
<wa-dropdown-item slot="submenu" type="checkbox" value="password">Password protect</wa-dropdown-item>
</wa-dropdown-item>
</wa-dropdown>
</div>
<script>
const container = document.querySelector('.dropdown-submenus');
const dropdown = container.querySelector('wa-dropdown');
dropdown.addEventListener('wa-select', event => {
console.log(event.detail.item.value);
});
</script>
<wa-menu-item value="cut">Cut</wa-menu-item>
<wa-menu-item value="copy">Copy</wa-menu-item>
<wa-menu-item value="paste">Paste</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item>
Find
<wa-menu slot="submenu">
<wa-menu-item value="find">Find…</wa-menu-item>
<wa-menu-item value="find-previous">Find Next</wa-menu-item>
<wa-menu-item value="find-next">Find Previous</wa-menu-item>
</wa-menu>
</wa-menu-item>
<wa-menu-item>
Transformations
<wa-menu slot="submenu">
<wa-menu-item value="uppercase">Make uppercase</wa-menu-item>
<wa-menu-item value="lowercase">Make lowercase</wa-menu-item>
<wa-menu-item value="capitalize">Capitalize</wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
</wa-dropdown>
```
:::info
Dropdown items that have a submenu will not dispatch the `wa-select` event. However, items inside the submenu will, unless they also have a submenu.
:::
:::warning
As a UX best practice, avoid using more than one level of submenu when possible.
:::
### Disabling Items
Add the `disabled` attribute to any [dropdown item](/docs/components/dropdown-item) to disable it.
```html {.example}
<wa-dropdown>
<wa-button slot="trigger" caret>Payment method</wa-button>
<wa-dropdown-item value="cash">Cash</wa-dropdown-item>
<wa-dropdown-item value="check" disabled>Personal check</wa-dropdown-item>
<wa-dropdown-item value="credit">Credit card</wa-dropdown-item>
<wa-dropdown-item value="gift-card">Gift card</wa-dropdown-item>
</wa-dropdown>
```

View File

@@ -0,0 +1,76 @@
---
title: Icon Button
description: Icons buttons are simple, icon-only buttons that can be used for actions and in toolbars.
tags: [actions, apps]
icon: icon-button
---
For a full list of icons that come bundled with Web Awesome, refer to the [icon component](/docs/components/icon).
```html {.example}
<wa-icon-button name="gear" label="Settings"></wa-icon-button>
```
## Examples
### Sizes
Icon buttons inherit their parent element's `font-size`.
```html {.example}
<wa-icon-button name="pen-to-square" variant="solid" label="Edit" style="font-size: 1.5rem;"></wa-icon-button>
<wa-icon-button name="pen-to-square" variant="solid" label="Edit" style="font-size: 2rem;"></wa-icon-button>
<wa-icon-button name="pen-to-square" variant="solid" label="Edit" style="font-size: 2.5rem;"></wa-icon-button>
```
### Colors
Icon buttons are designed to have a uniform appearance, so their color is not inherited. However, you can still customize them by styling the `base` part.
```html {.example}
<div class="icon-button-color">
<wa-icon-button name="bold" variant="solid" label="Bold"></wa-icon-button>
<wa-icon-button name="italic" variant="solid" label="Italic"></wa-icon-button>
<wa-icon-button name="underline" variant="solid" label="Underline"></wa-icon-button>
</div>
<style>
.icon-button-color wa-icon-button::part(base) {
color: #b00091;
}
.icon-button-color wa-icon-button::part(base):hover,
.icon-button-color wa-icon-button::part(base):focus {
color: #c913aa;
}
.icon-button-color wa-icon-button::part(base):active {
color: #960077;
}
</style>
```
### Link Buttons
Use the `href` attribute to convert the button to a link.
```html {.example}
<wa-icon-button name="gear" variant="solid" label="Settings" href="https://example.com" target="_blank"></wa-icon-button>
```
### Icon Button with Tooltip
Add a tooltip that references the `id` of the icon button to provide contextual information.
```html {.example}
<wa-icon-button id="icon-button" name="gear" variant="solid" label="Settings"></wa-icon-button>
<wa-tooltip for="icon-button">Settings</wa-tooltip>
```
### Disabled
Use the `disabled` attribute to disable the icon button.
```html {.example}
<wa-icon-button name="gear" variant="solid" label="Settings" disabled></wa-icon-button>
```

View File

@@ -0,0 +1,125 @@
---
title: Menu Item
description: Menu items provide options for the user to pick from in a menu.
tags: component
parent: menu
icon: menu
---
```html {.example}
<wa-menu style="max-width: 200px;">
<wa-menu-item>Option 1</wa-menu-item>
<wa-menu-item>Option 2</wa-menu-item>
<wa-menu-item>Option 3</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item type="checkbox" checked>Checkbox</wa-menu-item>
<wa-menu-item disabled>Disabled</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item>
Prefix Icon
<wa-icon slot="prefix" name="gift" variant="solid"></wa-icon>
</wa-menu-item>
<wa-menu-item>
Suffix Icon
<wa-icon slot="suffix" name="heart" variant="solid"></wa-icon>
</wa-menu-item>
</wa-menu>
```
## Examples
### Prefix & Suffix
Add content to the start and end of menu items using the `prefix` and `suffix` slots.
```html {.example}
<wa-menu style="max-width: 200px;">
<wa-menu-item>
<wa-icon slot="prefix" name="house" variant="solid"></wa-icon>
Home
</wa-menu-item>
<wa-menu-item>
<wa-icon slot="prefix" name="envelope" variant="solid"></wa-icon>
Messages
<wa-badge slot="suffix" variant="brand" pill>12</wa-badge>
</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item>
<wa-icon slot="prefix" name="gear" variant="solid"></wa-icon>
Settings
</wa-menu-item>
</wa-menu>
```
### Disabled
Add the `disabled` attribute to disable the menu item so it cannot be selected.
```html {.example}
<wa-menu style="max-width: 200px;">
<wa-menu-item>Option 1</wa-menu-item>
<wa-menu-item disabled>Option 2</wa-menu-item>
<wa-menu-item>Option 3</wa-menu-item>
</wa-menu>
```
### Loading
Use the `loading` attribute to indicate that a menu item is busy. Like a disabled menu item, clicks will be suppressed until the loading state is removed.
```html {.example}
<wa-menu style="max-width: 200px;">
<wa-menu-item>Option 1</wa-menu-item>
<wa-menu-item loading>Option 2</wa-menu-item>
<wa-menu-item>Option 3</wa-menu-item>
</wa-menu>
```
### Checkbox Menu Items
Set the `type` attribute to `checkbox` to create a menu item that will toggle on and off when selected. You can use the `checked` attribute to set the initial state.
Checkbox menu items are visually indistinguishable from regular menu items. Their ability to be toggled is primarily inferred from context, much like you'd find in the menu of a native app.
```html {.example}
<wa-menu style="max-width: 200px;">
<wa-menu-item type="checkbox">Autosave</wa-menu-item>
<wa-menu-item type="checkbox" checked>Check Spelling</wa-menu-item>
<wa-menu-item type="checkbox">Word Wrap</wa-menu-item>
</wa-menu>
```
### Value & Selection
The `value` attribute can be used to assign a hidden value, such as a unique identifier, to a menu item. When an item is selected, the `wa-select` event will be emitted and a reference to the item will be available at `event.detail.item`. You can use this reference to access the selected item's value, its checked state, and more.
```html {.example}
<wa-menu class="menu-value" style="max-width: 200px;">
<wa-menu-item value="opt-1">Option 1</wa-menu-item>
<wa-menu-item value="opt-2">Option 2</wa-menu-item>
<wa-menu-item value="opt-3">Option 3</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item type="checkbox" value="opt-4">Checkbox 4</wa-menu-item>
<wa-menu-item type="checkbox" value="opt-5">Checkbox 5</wa-menu-item>
<wa-menu-item type="checkbox" value="opt-6">Checkbox 6</wa-menu-item>
</wa-menu>
<script>
const menu = document.querySelector('.menu-value');
menu.addEventListener('wa-select', event => {
const item = event.detail.item;
// Log value
if (item.type === 'checkbox') {
console.log(`Selected value: ${item.value} (${item.checked ? 'checked' : 'unchecked'})`);
} else {
console.log(`Selected value: ${item.value}`);
}
});
</script>
```

View File

@@ -0,0 +1,21 @@
---
title: Menu Label
description: Menu labels are used to describe a group of menu items.
tags: component
parent: menu
icon: menu
---
```html {.example}
<wa-menu style="max-width: 200px;">
<wa-menu-label>Fruits</wa-menu-label>
<wa-menu-item value="apple">Apple</wa-menu-item>
<wa-menu-item value="banana">Banana</wa-menu-item>
<wa-menu-item value="orange">Orange</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-label>Vegetables</wa-menu-label>
<wa-menu-item value="broccoli">Broccoli</wa-menu-item>
<wa-menu-item value="carrot">Carrot</wa-menu-item>
<wa-menu-item value="zucchini">Zucchini</wa-menu-item>
</wa-menu>
```

View File

@@ -0,0 +1,77 @@
---
title: Menu
description: Menus provide a list of options for the user to choose from.
tags: [actions, apps]
icon: menu
---
You can use [menu items](/docs/components/menu-item), [menu labels](/docs/components/menu-label), and [dividers](/docs/components/divider) to compose a menu. Menus support keyboard interactions, including type-to-select an option.
```html {.example}
<wa-menu style="max-width: 200px;">
<wa-menu-item value="undo">Undo</wa-menu-item>
<wa-menu-item value="redo">Redo</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item value="cut">Cut</wa-menu-item>
<wa-menu-item value="copy">Copy</wa-menu-item>
<wa-menu-item value="paste">Paste</wa-menu-item>
<wa-menu-item value="delete">Delete</wa-menu-item>
</wa-menu>
```
:::info
Menus are intended for system menus (dropdown menus, select menus, context menus, etc.). They should not be mistaken for navigation menus which serve a different purpose and have a different semantic meaning. If you're building navigation, use `<nav>` and `<a>` elements instead.
:::
## Examples
### In Dropdowns
Menus work really well when used inside [dropdowns](/docs/components/dropdown).
```html {.example}
<wa-dropdown>
<wa-button slot="trigger" caret>Edit</wa-button>
<wa-menu>
<wa-menu-item value="cut">Cut</wa-menu-item>
<wa-menu-item value="copy">Copy</wa-menu-item>
<wa-menu-item value="paste">Paste</wa-menu-item>
</wa-menu>
</wa-dropdown>
```
### Submenus
To create a submenu, nest an `<wa-menu slot="submenu">` in any [menu item](/docs/components/menu-item).
```html {.example}
<wa-menu style="max-width: 200px;">
<wa-menu-item value="undo">Undo</wa-menu-item>
<wa-menu-item value="redo">Redo</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item value="cut">Cut</wa-menu-item>
<wa-menu-item value="copy">Copy</wa-menu-item>
<wa-menu-item value="paste">Paste</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item>
Find
<wa-menu slot="submenu">
<wa-menu-item value="find">Find…</wa-menu-item>
<wa-menu-item value="find-previous">Find Next</wa-menu-item>
<wa-menu-item value="find-next">Find Previous</wa-menu-item>
</wa-menu>
</wa-menu-item>
<wa-menu-item>
Transformations
<wa-menu slot="submenu">
<wa-menu-item value="uppercase">Make uppercase</wa-menu-item>
<wa-menu-item value="lowercase">Make lowercase</wa-menu-item>
<wa-menu-item value="capitalize">Capitalize</wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
```
:::warning
As a UX best practice, avoid using more than one level of submenus when possible.
:::

View File

@@ -38,19 +38,15 @@ Use the default slot to show a value.
<wa-progress-bar value="50" id="progress-bar-demo">50%</wa-progress-bar>
<div>
<wa-button pill appearance="filled">
<wa-icon name="minus" label="Decrease"></wa-icon>
</wa-button>
<wa-button pill appearance="filled">
<wa-icon name="plus" label="Increase"></wa-icon>
</wa-button>
<wa-icon-button pill name="minus" label="Decrease"></wa-icon-button>
<wa-icon-button pill name="plus" label="Increase"></wa-icon-button>
</div>
</div>
<script>
const progressBar = document.querySelector('#progress-bar-demo');
const subtractButton = document.querySelector('wa-button:has(wa-icon[name="minus"])');
const addButton = document.querySelector('wa-button:has(wa-icon[name="plus"])');
const subtractButton = document.querySelector('wa-icon-button[name="minus"]');
const addButton = document.querySelector('wa-icon-button[name="plus"]');
addButton.addEventListener('click', () => {
const value = Math.min(100, progressBar.value + 10);

View File

@@ -7,20 +7,7 @@ icon: slider
---
```html {.example}
<wa-slider
label="Number of cats"
hint="Limit six per household"
name="value"
value="3"
min="0"
max="6"
with-markers
with-tooltip
with-references
>
<span slot="reference">Less</span>
<span slot="reference">More</span>
</wa-slider>
<wa-slider></wa-slider>
```
:::info
@@ -31,7 +18,7 @@ This component works with standard `<form>` elements. Please refer to the sectio
### Labels
Use the `label` attribute to give the slider an accessible label. For labels that contain HTML, use the `label` slot instead.
Use the `label` attribute to give the range an accessible label. For labels that contain HTML, use the `label` slot instead.
```html {.example}
<wa-slider label="Volume" min="0" max="100"></wa-slider>
@@ -39,233 +26,18 @@ Use the `label` attribute to give the slider an accessible label. For labels tha
### Hint
Add descriptive hint to a slider with the `hint` attribute. For hints that contain HTML, use the `hint` slot instead.
Add descriptive hint to a range with the `hint` attribute. For hints that contain HTML, use the `hint` slot instead.
```html {.example}
<wa-slider label="Volume" hint="Controls the volume of the current song." min="0" max="100"></wa-slider>
```
### Showing tooltips
### Min, Max, and Step
Use the `with-tooltip` attribute to display a tooltip with the current value when the slider is focused or being dragged.
Use the `min` and `max` attributes to set the range's minimum and maximum values, respectively. The `step` attribute determines the value's interval when increasing and decreasing.
```html {.example}
<wa-slider label="Quality" name="quality" min="0" max="100" value="50" with-tooltip></wa-slider>
```
### Setting min, max, and step
Use the `min` and `max` attributes to define the slider's range, and the `step` attribute to control the increment between values.
```html {.example}
<wa-slider label="Between zero and one" min="0" max="1" step="0.1" value="0.5" with-tooltip></wa-slider>
```
### Showing markers
Use the `with-markers` attribute to display visual indicators at each step increment. This works best with sliders that have a smaller range of values.
```html {.example}
<wa-slider label="Size" name="size" min="0" max="8" value="4" with-markers></wa-slider>
```
### Adding references
Use the `with-references` attribute along with the `reference` slot to add contextual labels below the slider. References are automatically spaced using `space-between`, making them easy to align with the start, center, and end positions.
```html {.example}
<wa-slider label="Speed" name="speed" min="1" max="5" value="3" with-markers with-references>
<span slot="reference">Slow</span>
<span slot="reference">Medium</span>
<span slot="reference">Fast</span>
</wa-slider>
```
:::info
If you want to show a reference next to a specific marker, you can add `position: absolute` to it and set the `left`, `right`, `top`, or `bottom` property to a percentage that corresponds to the marker's position.
:::
### Formatting the value
Customize how values are displayed in tooltips and announced to screen readers using the `valueFormatter` property. Set it to a function that accepts a number and returns a formatted string. The [`Intl.NumberFormat API`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) is particularly useful for this.
```html {.example}
<!-- Percent -->
<wa-slider
id="slider__percent"
label="Percentage"
name="percentage"
value="0.5"
min="0"
max="1"
step=".01"
with-tooltip
></wa-slider
><br />
<script>
const slider = document.getElementById('slider__percent');
const formatter = new Intl.NumberFormat('en-US', { style: 'percent' });
customElements.whenDefined('wa-slider').then(() => {
slider.valueFormatter = value => formatter.format(value);
});
</script>
<!-- Duration -->
<wa-slider id="slider__duration" label="Duration" name="duration" value="12" min="0" max="24" with-tooltip></wa-slider
><br />
<script>
const slider = document.getElementById('slider__duration');
const formatter = new Intl.NumberFormat('en-US', { style: 'unit', unit: 'hour', unitDisplay: 'long' });
customElements.whenDefined('wa-slider').then(() => {
slider.valueFormatter = value => formatter.format(value);
});
</script>
<!-- Currency -->
<wa-slider id="slider__currency" label="Currency" name="currency" min="0" max="100" value="50" with-tooltip></wa-slider>
<script>
const slider = document.getElementById('slider__currency');
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
currencyDisplay: 'symbol',
maximumFractionDigits: 0,
});
customElements.whenDefined('wa-slider').then(() => {
slider.valueFormatter = value => formatter.format(value);
});
</script>
```
### Range selection
Use the `range` attribute to enable dual-thumb selection for choosing a range of values. Set the initial thumb positions with the `min-value` and `max-value` attributes.
```html {.example}
<wa-slider
label="Price Range"
hint="Select minimum and maximum price"
name="price"
range
min="0"
max="100"
min-value="20"
max-value="80"
with-tooltip
with-references
id="slider__range"
>
<span slot="reference">$0</span>
<span slot="reference">$50</span>
<span slot="reference">$100</span>
</wa-slider>
<script>
const slider = document.getElementById('slider__range');
const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
customElements.whenDefined('wa-slider').then(() => {
slider.valueFormatter = value => formatter.format(value);
});
</script>
```
For range sliders, the `minValue` and `maxValue` properties represent the current positions of the thumbs. When the form is submitted, both values will be included as separate entries with the same name.
```ts
const slider = document.querySelector('wa-slider[range]');
// Get the current values
console.log(`Min value: ${slider.minValue}, Max value: ${slider.maxValue}`);
// Set the values programmatically
slider.minValue = 30;
slider.maxValue = 70;
```
### Vertical Sliders
Set the `orientation` attribute to `vertical` to create a vertical slider. Vertical sliders automatically center themselves and fill the available vertical space.
```html {.example}
<div style="display: flex; gap: 1rem;">
<wa-slider orientation="vertical" label="Volume" name="volume" value="65" style="width: 80px"></wa-slider>
<wa-slider orientation="vertical" label="Bass" name="bass" value="50" style="width: 80px"></wa-slider>
<wa-slider orientation="vertical" label="Treble" name="treble" value="40" style="width: 80px"></wa-slider>
</div>
```
Range sliders can also be vertical.
```html {.example}
<div style="height: 300px; display: flex; align-items: center; gap: 2rem;">
<wa-slider
label="Temperature Range"
orientation="vertical"
range
min="0"
max="100"
min-value="30"
max-value="70"
with-tooltip
tooltip-placement="right"
id="slider__vertical-range"
>
</wa-slider>
</div>
<script>
const slider = document.getElementById('slider__vertical-range');
slider.valueFormatter = value => {
return new Intl.NumberFormat('en', {
style: 'unit',
unit: 'fahrenheit',
unitDisplay: 'short',
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value);
};
</script>
```
### Size
Control the slider's size using the `size` attribute. Valid options include `small`, `medium`, and `large`.
```html {.example}
<wa-slider size="small" value="50" label="Small"></wa-slider><br />
<wa-slider size="medium" value="50" label="Medium"></wa-slider><br />
<wa-slider size="large" value="50" label="Large"></wa-slider>
```
### Indicator Offset
By default, the filled indicator extends from the minimum value to the current position. Use the `indicator-offset` attribute to change the starting point of this visual indicator.
```html {.example}
<wa-slider
label="Cat playfulness"
hint="Energy level during playtime"
name="value"
value="0"
min="-5"
max="5"
indicator-offset="0"
with-markers
with-tooltip
with-references
>
<span slot="reference">Lazy</span>
<span slot="reference">Zoomies</span>
</wa-slider>
<wa-slider min="0" max="10" step="1"></wa-slider>
```
### Disabled
@@ -273,17 +45,74 @@ By default, the filled indicator extends from the minimum value to the current p
Use the `disabled` attribute to disable a slider.
```html {.example}
<wa-slider label="Disabled" value="50" disabled></wa-slider>
<wa-slider disabled></wa-slider>
```
### Required
### Tooltip Placement
Mark a slider as required using the `required` attribute. Users must interact with required sliders before the form can be submitted.
By default, the tooltip is shown on top. Set `tooltip` to `bottom` to show it below the slider.
```html {.example}
<form action="about:blank" target="_blank" method="get">
<wa-slider name="slide" label="Required slider" min="0" max="10" required></wa-slider>
<br />
<button type="submit">Submit</button>
</form>
```
<wa-slider tooltip="bottom"></wa-slider>
```
### Disable the Tooltip
To disable the tooltip, set `tooltip` to `none`.
```html {.example}
<wa-slider tooltip="none"></wa-slider>
```
### Custom Track Colors
You can customize the active and inactive portions of the track using the `--track-color-active` and `--track-color-inactive` custom properties.
```html {.example}
<wa-slider
style="
--track-color-active: var(--wa-color-brand-fill-loud);
--track-color-inactive: var(--wa-color-brand-fill-normal);
"
></wa-slider>
```
### Custom Track Offset
You can customize the initial offset of the active track using the `--track-active-offset` custom property.
```html {.example}
<wa-slider
min="-100"
max="100"
style="
--track-color-active: var(--wa-color-brand-fill-loud);
--track-color-inactive: var(--wa-color-brand-fill-normal);
--track-active-offset: 50%;
"
></wa-slider>
```
### Custom Tooltip Formatter
You can change the tooltip's content by setting the `tooltipFormatter` property to a function that accepts the range's value as an argument.
```html {.example}
<wa-slider min="0" max="100" step="1" class="range-with-custom-formatter"></wa-slider>
<script>
const range = document.querySelector('.range-with-custom-formatter');
range.tooltipFormatter = value => `Total - ${value}%`;
</script>
```
### Right-to-Left languages
The component adapts to right-to-left (RTL) languages as you would expect.
```html {.example}
<wa-slider dir="rtl"
label="مقدار"
hint="التحكم في مستوى صوت الأغنية الحالية."
style="--track-color-active: var(--wa-color-brand-fill-loud)" value="10"></wa-slider>
```

View File

@@ -101,9 +101,7 @@ You can make a tab closable by adding a close button next to the tab and inside
<wa-tab-group class="tabs-closable">
<wa-tab panel="general">General</wa-tab>
<wa-tab panel="closable">Closable</wa-tab>
<wa-button slot="nav" tabindex="-1" appearance="plain" size="small">
<wa-icon name="xmark" label="Close the closable tab"></wa-icon>
</wa-button>
<wa-icon-button slot="nav" tabindex="-1" name="xmark" label="Close the closable tab"></wa-icon-button>
<wa-tab panel="closable-2">Advanced</wa-tab>
<wa-tab-panel name="general">This is the general tab panel.</wa-tab-panel>
@@ -116,17 +114,17 @@ You can make a tab closable by adding a close button next to the tab and inside
<wa-button disabled>Restore tab</wa-button>
<style>
.tabs-closable wa-button {
.tabs-closable wa-icon-button {
position: relative;
left: -1.5em;
top: 0.675em; }
left: -1rem;
top: .75rem; }
</style>
<script>
const tabGroup = document.querySelector('.tabs-closable');
const generalTab = tabGroup.querySelectorAll('wa-tab')[0];
const closableTab = tabGroup.querySelectorAll('wa-tab')[1];
const closeButton = tabGroup.querySelector('wa-button');
const closeButton = tabGroup.querySelector('wa-icon-button');
const restoreButton = tabGroup.nextElementSibling.nextElementSibling;
// Remove the tab when the close button is clicked

View File

@@ -1,79 +0,0 @@
---
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

@@ -1731,7 +1731,7 @@ hasOutline: false
grid-column-end: col-end;
}
.project-header wa-button {
.project-header wa-icon-button {
color: inherit;
font-size: var(--wa-font-size-l);
@@ -2002,15 +2002,9 @@ hasOutline: false
<span id="project-name" style="margin-inline-start: var(--wa-space-l);">Project Name</span>
</h1>
<div>
<wa-button appearance="plain" size="small">
<wa-icon name="magnifying-glass" label="Search"></wa-icon>
</wa-button>
<wa-button appearance="plain" size="small">
<wa-icon name="user" label="Account"></wa-icon>
</wa-button>
<wa-button appearance="plain" size="small">
<wa-icon name="bag-shopping" label="Your Basket"></wa-icon>
</wa-button>
<wa-icon-button name="magnifying-glass" label="Search"></wa-icon-button>
<wa-icon-button name="user" label="Account"></wa-icon-button>
<wa-icon-button name="bag-shopping" label="Your Basket"></wa-icon-button>
</div>
</header>
<section class="strata hero">
@@ -2148,49 +2142,31 @@ hasOutline: false
<wa-card class="card-header card-footer">
<div slot="header">
<div class="grouped-buttons">
<wa-button appearance="plain" size="small" id="bold">
<wa-icon name="bold" label="Bold"></wa-icon>
</wa-button>
<wa-icon-button id="bold" name="bold" label="Bold"></wa-icon-button>
<wa-tooltip for="bold">Bold</wa-tooltip>
<wa-button appearance="plain" size="small" id="italic">
<wa-icon name="italic" label="Italic"></wa-icon>
</wa-button>
<wa-icon-button id="italic" name="italic" label="Italic"></wa-icon-button>
<wa-tooltip for="italic">Italic</wa-tooltip>
<wa-button appearance="plain" size="small" id="strikethrough">
<wa-icon name="strikethrough" label="strikethrough"></wa-icon>
</wa-button>
<wa-icon-button id="strikethrough" name="strikethrough" label="strikethrough"></wa-icon-button>
<wa-tooltip for="strikethrough">Strikethrough</wa-tooltip>
</div>
<div class="grouped-buttons">
<wa-button appearance="plain" size="small" id="link">
<wa-icon name="link" label="Link"></wa-icon>
</wa-button>
<wa-icon-button id="link" name="link" label="Link"></wa-icon-button>
<wa-tooltip for="link">Link</wa-tooltip>
</div>
<div class="grouped-buttons">
<wa-button appearance="plain" size="small" id="list">
<wa-icon name="list" label="Unordered List"></wa-icon>
</wa-button>
<wa-icon-button id="list" name="list" label="Unordered List"></wa-icon-button>
<wa-tooltip for="list">Unordered List</wa-tooltip>
<wa-button appearance="plain" size="small" id="list-ol">
<wa-icon name="list-ol" label="Ordered List"></wa-icon>
</wa-button>
<wa-icon-button id="list-ol" name="list-ol" label="Ordered List"></wa-icon-button>
<wa-tooltip for="list-ol">Ordered List</wa-tooltip>
</div>
<div class="grouped-buttons">
<wa-button appearance="plain" size="small" id="block-quote">
<wa-icon name="block-quote" label="Block Quote"></wa-icon>
</wa-button>
<wa-icon-button id="block-quote" name="block-quote" label="Block Quote"></wa-icon-button>
<wa-tooltip for="block-quote">Block Quote</wa-tooltip>
</div>
<div class="grouped-buttons">
<wa-button appearance="plain" size="small" id="code">
<wa-icon name="code" label="Code"></wa-icon>
</wa-button>
<wa-icon-button id="code" name="code" label="Code"></wa-icon-button>
<wa-tooltip for="code">Code</wa-tooltip>
<wa-button appearance="plain" size="small" id="inline-code">
<wa-icon name="terminal" label="Inline Code"></wa-icon>
</wa-button>
<wa-icon-button id="inline-code" name="terminal" label="Inline Code"></wa-icon-button>
<wa-tooltip for="inline-code">Inline Code</wa-tooltip>
</div>
</div>
@@ -2200,37 +2176,23 @@ hasOutline: false
<div slot="footer">
<div class="tools">
<div class="grouped-buttons">
<wa-button appearance="plain" size="small" id="add-file">
<wa-icon name="circle-plus" label="Add File"></wa-icon>
</wa-button>
<wa-icon-button id="add-file" name="circle-plus" label="Add File"></wa-icon-button>
<wa-tooltip for="add-file">Add File</wa-tooltip>
<wa-button appearance="plain" size="small" id="formatting">
<wa-icon name="font-case" label="Open Formatting"></wa-icon>
</wa-button>
<wa-icon-button id="formatting" name="font-case" label="Open Formatting"></wa-icon-button>
<wa-tooltip for="formatting">Formatting</wa-tooltip>
<wa-button appearance="plain" size="small" id="emojis">
<wa-icon name="face-smile" label="Emoji"></wa-icon>
</wa-button>
<wa-icon-button id="emojis" name="face-smile" label="Emoji"></wa-icon-button>
<wa-tooltip for="emojis">Emojis</wa-tooltip>
<wa-button appearance="plain" size="small" id="mention">
<wa-icon name="at" label="Mention"></wa-icon>
</wa-button>
<wa-icon-button id="mention" name="at" label="Mention"></wa-icon-button>
<wa-tooltip for="mention">Mention</wa-tooltip>
</div>
<div class="grouped-buttons">
<wa-button appearance="plain" size="small" id="record-video">
<wa-icon name="video" label="Video"></wa-icon>
</wa-button>
<wa-icon-button id="record-video" name="video" label="Video"></wa-icon-button>
<wa-tooltip for="record-video">Record Video</wa-tooltip>
<wa-button appearance="plain" size="small" id="record-audio">
<wa-icon name="microphone" label="Microphone"></wa-icon>
</wa-button>
<wa-icon-button id="record-audio" name="microphone" label="Microphone"></wa-icon-button>
<wa-tooltip for="record-audio">Record Audio Clip</wa-tooltip>
</div>
<div class="grouped-buttons">
<wa-button appearance="plain" size="small" id="add-magic">
<wa-icon name="sparkles" label="Magic"></wa-icon>
</wa-button>
<wa-icon-button id="add-magic" name="sparkles" label="Magic"></wa-icon-button>
<wa-tooltip for="add-magic">Add Magic</wa-tooltip>
</div>
</div>

View File

@@ -31,36 +31,20 @@ During the alpha period, things might break! We take breaking changes very serio
- `<wa-tab-group no-scroll-controls>` => `<wa-tab-group without-scroll-controls>`
- `<wa-tag removable>` => `<wa-tag with-remove>`
- 🚨 BREAKING: removed the `size` attribute from `<wa-card>`; please set the size of child elements on the children directly
- 🚨 BREAKING: greatly simplified the sizing strategy across components and utilities
- 🚨 BREAKING: Greatly simplified the sizing strategy across components and utilities
- Removed `--wa-size`, `--wa-size-smaller`, `--wa-size-larger`, `--wa-space`, `--wa-space-smaller`, and `--wa-space-larger`
- Added tokens for `--wa-form-control-padding-inline`, `--wa-form-control-padding-block`, and `--wa-form-control-toggle-size`
- Refactored default `--wa-font-size-*` values to use an apparent 1.125 ratio and round rendered values to the nearest whole pixel
- Added convenience tokens for `--wa-font-size-smaller` and `--wa-font-size-larger`
- Updated components to use relative `em` values for internal padding and margin wherever appropriate
- 🚨 BREAKING: removed the `hint` property and slot from `<wa-radio>`; please apply hints directly to `<wa-radio-group>` instead
- 🚨 BREAKING: redesigned `<wa-slider>` with extensive new functionality
- Added support for range sliders with dual thumbs using the `range` attribute
- Added vertical orientation support with `orientation="vertical"`
- Added visual markers at each step with `with-markers`
- Added contextual reference labels with `with-references` and the `reference` slot
- Added tooltips showing current values with `with-tooltip`
- Added customizable indicator offset with `indicator-offset` attribute
- Added value formatting support with the `valueFormatter` property
- Improved the styling API to be consistent and more powerful (no more browser-specific selectors and pseudo elements to style)
- Updated to use consistent `with-*` attribute naming pattern
- 🚨 BREAKING: removed `<wa-icon-button>`; use `<wa-button><wa-icon name="..." label="..."></wa-icon></wa-button>` instead
- 🚨 BREAKING: completely reworked `<wa-dropdown>` to be easier to use
- Added `<wa-dropdown-item>`, greatly simplifying the dropdown's markup structure
- Removed `<wa-menu>`, `<wa-menu-item>`, and `<wa-menu-label>`; use `<wa-dropdown-item>` and native headings 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
@@ -75,8 +59,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)
@@ -138,7 +122,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
@@ -163,21 +147,20 @@ During the alpha period, things might break! We take breaking changes very serio
- 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
@@ -363,12 +346,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

@@ -23,13 +23,15 @@ unlisted: true
<div class="title">
<h1><editable-text :model-value="title" label="theme name" @submit="newTitle => save({title: newTitle})" blur="cancel"></editable-text></h1>
<wa-button v-if="saved" class="delete" @click="deleteSaved" appearance="plain" size="small">
<wa-icon name="trash" label="Delete theme"></wa-icon>
</wa-button>
<wa-icon-button v-if="saved" class="delete" name="trash" label="Delete theme" @click="deleteSaved"></wa-icon-button>
</div>
<wa-button v-if="tweaked || uid" @click="save()" :disabled="!unsavedChanges"
:variant="unsavedChanges ? 'success' : 'neutral'" size="small" :appearance="unsavedChanges ? 'accent' : 'outlined'">
<span slot="prefix" class="icon-modifier">
<wa-icon name="sidebar" variant="regular"></wa-icon>
<wa-icon name="circle-plus" class="modifier" style="color: light-dark(var(--wa-color-green-70), var(--wa-color-green-60));"></wa-icon>
</span>
<span v-content="unsavedChanges ? 'Save' : 'Saved'">Save</span>
</wa-button>
<wa-button size="small" @click="ui.showCode = !showCode" appearance="outlined">

View File

@@ -45,11 +45,11 @@ wa-page > header {
margin: 0;
}
wa-button {
wa-icon-button {
transition: var(--wa-transition-slow);
}
&:not(:hover, :focus-within, :has(input)) wa-button {
&:not(:hover, :focus-within, :has(input)) wa-icon-button {
opacity: 0;
}
}

View File

@@ -83,7 +83,7 @@ wa-input::part(input) {
grid-column-end: col-end;
}
.project-header wa-button {
.project-header wa-icon-button {
color: inherit;
font-size: var(--wa-font-size-l);

View File

@@ -21,15 +21,9 @@ noTheme: true
<span id="project-name" style="margin-inline-start: var(--wa-space-l);">Project Name</span>
</h1>
<div>
<wa-button appearance="plain">
<wa-icon name="magnifying-glass" label="Search"></wa-icon>
</wa-button>
<wa-button appearance="plain">
<wa-icon name="user" label="Account"></wa-icon>
</wa-button>
<wa-button appearance="plain">
<wa-icon name="bag-shopping" label="Your Basket"></wa-icon>
</wa-button>
<wa-icon-button name="magnifying-glass" label="Search"></wa-icon-button>
<wa-icon-button name="user" label="Account"></wa-icon-button>
<wa-icon-button name="bag-shopping" label="Your Basket"></wa-icon-button>
</div>
</header>
<section class="strata hero">

View File

@@ -96,8 +96,6 @@ Tooltip styles are shared between the [tooltip](/docs/components/tooltip) compon
| `--wa-tooltip-line-height` | `var(--wa-line-height-normal)` |
```html {.example}
<wa-button id="bullseye-example" appearance="plain">
<wa-icon label="Target" name="bullseye"></wa-icon>
</wa-button>
<wa-icon-button id="bullseye-example" label="Button" name="bullseye"></wa-icon-button>
<wa-tooltip for="bullseye-example" open trigger="manual">This is a tooltip</wa-tooltip>
```

View File

@@ -40,9 +40,7 @@ Frames are well-suited for images and image placeholders.
<h3>The Lord of the Rings: The Fellowship of the Ring</h3>
<span>J.R.R. Tolkien</span>
</div>
<wa-button id="options-menu" appearance="plain">
<wa-icon name="ellipsis" label="Options"></wa-icon>
</wa-button>
<wa-icon-button id="options-menu" name="ellipsis"></wa-icon-button>
<wa-tooltip for="options-menu">Options</wa-tooltip>
</div>
</div>
@@ -59,9 +57,7 @@ Frames are well-suited for images and image placeholders.
<span class="wa-body-s">Kitten &bull; Male</span>
<div class="wa-flank:end wa-gap-xs">
<wa-button size="small" appearance="filled" variant="brand">Adopt this pet</wa-button>
<wa-button id="fav-whitesocks" appearance="plain" size="small">
<wa-icon name="heart" variant="regular" label="Favorite"></wa-icon>
</wa-button>
<wa-icon-button id="fav-whitesocks" name="heart" variant="regular"></wa-icon-button>
<wa-tooltip for="fav-whitesocks">Favorite</wa-tooltip>
</div>
</div>
@@ -78,9 +74,7 @@ Frames are well-suited for images and image placeholders.
<span class="wa-body-s">Adult &bull; Male</span>
<div class="wa-flank:end wa-gap-xs">
<wa-button size="small" appearance="filled" variant="brand">Adopt this pet</wa-button>
<wa-button id="fav-bumpkin" appearance="plain" size="small">
<wa-icon name="heart" variant="regular" label="Favorite"></wa-icon>
</wa-button>
<wa-icon-button id="fav-bumpkin" name="heart" variant="regular"></wa-icon-button>
<wa-tooltip for="fav-bumpkin">Favorite</wa-tooltip>
</div>
</div>
@@ -94,9 +88,7 @@ Frames are well-suited for images and image placeholders.
<span class="wa-body-s">Kitten &bull; Female</span>
<div class="wa-flank:end wa-gap-xs">
<wa-button size="small" appearance="filled" variant="brand">Adopt this pet</wa-button>
<wa-button id="fav-swishtail" appearance="plain" size="small">
<wa-icon name="heart" variant="regular" label="Favorite"></wa-icon>
</wa-button>
<wa-icon-button id="fav-swishtail" name="heart" variant="regular"></wa-icon-button>
<wa-tooltip for="fav-swishtail">Favorite</wa-tooltip>
</div>
</div>
@@ -110,9 +102,7 @@ Frames are well-suited for images and image placeholders.
<span class="wa-body-s">Adult &bull; Female</span>
<div class="wa-flank:end wa-gap-xs">
<wa-button size="small" appearance="filled" variant="brand">Adopt this pet</wa-button>
<wa-button id="fav-sharpears" appearance="plain" size="small">
<wa-icon name="heart" variant="regular" label="Favorite"></wa-icon>
</wa-button>
<wa-icon-button id="fav-sharpears" name="heart" variant="regular"></wa-icon-button>
<wa-tooltip for="fav-sharpears">Favorite</wa-tooltip>
</div>
</div>

View File

@@ -36,21 +36,13 @@ Splits are especially helpful for navigation, header, and footer layouts.
<div class="wa-flank">
<div class="wa-split:column">
<div class="wa-stack">
<wa-button appearance="plain">
<wa-icon name="house" label="Home"></wa-icon>
</wa-button>
<wa-button appearance="plain">
<wa-icon name="calendar" label="Calendar"></wa-icon>
</wa-button>
<wa-button appearance="plain">
<wa-icon name="envelope" label="Mail"></wa-icon>
</wa-button>
<wa-icon-button name="house" label="Home"></wa-icon-button>
<wa-icon-button name="calendar" label="Calendar"></wa-icon-button>
<wa-icon-button name="envelope" label="Mail"></wa-icon-button>
</div>
<div class="wa-stack">
<wa-divider></wa-divider>
<wa-button appearance="plain">
<wa-icon name="right-from-bracket" label="Sign Out"></wa-icon>
</wa-button>
<wa-icon-button name="right-from-bracket" label="Sign Out"></wa-icon-button>
</div>
</div>
<div class="placeholder">

View File

@@ -216,6 +216,7 @@ layout: page
border-color: var(--wa-color-surface-border);
border-radius: 0.75rem;
color: var(--wa-color-text-normal);
display: block;
height: 100%;
line-height: var(--wa-line-height-normal);
padding: 1.25rem;
@@ -239,7 +240,7 @@ layout: page
font-weight: var(--wa-font-weight-normal);
}
&::part(label) {
flex-direction: column;
width: 100%;
}
}
wa-callout {

View File

@@ -149,14 +149,13 @@ export async function build(options = {}) {
if (process.env.ROOT_DIR) {
process.chdir(process.env.ROOT_DIR);
}
execSync(`tsc --project ./tsconfig.prod.json --outdir "${getCdnDir()}"`, { stdio: "inherit" });
execSync(`tsc --project ./tsconfig.prod.json --outdir "${getCdnDir()}"`);
process.chdir(cwd);
} catch (error) {
process.chdir(cwd);
if (!isDeveloping) {
process.exit(1);
}
return Promise.reject(error.stdout);
}

View File

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

View File

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

View File

@@ -172,6 +172,30 @@ describe('<wa-breadcrumb-item>', () => {
expect(childNodes.length).to.eq(1);
});
});
describe('when rendering a wa-dropdown in the default slot', () => {
it('should not render a link or button tag, but a div wrapper', async () => {
const el = await fixture<WaBreadcrumbItem>(html`
<wa-breadcrumb-item>
<wa-dropdown>
<wa-button slot="trigger" size="small" circle>
<wa-icon label="More options" name="ellipsis"></wa-icon>
</wa-button>
<wa-menu>
<wa-menu-item type="checkbox" checked>Web Design</wa-menu-item>
<wa-menu-item type="checkbox">Web Development</wa-menu-item>
<wa-menu-item type="checkbox">Marketing</wa-menu-item>
</wa-menu>
</wa-dropdown>
</wa-breadcrumb-item>
`);
await expect(el).to.be.accessible();
expect(el.shadowRoot!.querySelector('a')).to.be.null;
expect(el.shadowRoot!.querySelector('button')).to.be.null;
expect(el.shadowRoot!.querySelector('.label-dropdown')).not.to.be.null;
});
});
});
}
});

View File

@@ -12,8 +12,8 @@ import styles from './breadcrumb-item.css';
* @since 2.0
*
* @slot - The breadcrumb item's label.
* @slot prefix - An optional prefix, usually an icon.
* @slot suffix - An optional suffix, usually an icon.
* @slot prefix - An optional prefix, usually an icon or icon button.
* @slot suffix - An optional suffix, usually an icon or icon button.
* @slot separator - The separator to use for the breadcrumb item. This will only change the separator for this item. If
* you want to change it for all items in the group, set the separator on `<wa-breadcrumb>` instead.
*

View File

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

View File

@@ -51,12 +51,10 @@
}
/* Interactive states */
@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):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 {
@@ -91,13 +89,6 @@
border: 0;
}
/* Icon buttons */
.button.is-icon-button {
outline-offset: 2px;
width: var(--wa-form-control-height);
aspect-ratio: 1;
}
/* Pill modifier */
:host([pill]) .button {
border-radius: var(--wa-border-radius-pill);
@@ -116,11 +107,11 @@
}
.label {
display: flex;
display: inline-block;
}
.label::slotted(wa-icon) {
align-self: center;
vertical-align: -2px;
}
/*
@@ -173,7 +164,6 @@ wa-icon[part~='caret'] {
/*
* Badges
*/
button ::slotted(wa-badge) {
border-color: var(--wa-color-surface-default);
position: absolute;

View File

@@ -64,15 +64,13 @@ export default class WaButton extends WebAwesomeFormAssociatedElement {
private readonly localize = new LocalizeController(this);
@query('.button') button: HTMLButtonElement | HTMLLinkElement;
@query('slot:not([name])') labelSlot: HTMLSlotElement;
@state() invalid = false;
@state() isIconButton = false;
@property() title = ''; // make reactive to pass through
/** The button's theme variant. Defaults to `neutral` if not within another element with a variant. */
@property({ reflect: true }) variant: 'neutral' | 'brand' | 'success' | 'warning' | 'danger' = 'neutral';
@property({ reflect: true })
variant: 'neutral' | 'brand' | 'success' | 'warning' | 'danger' = 'neutral';
/** The button's visual appearance. */
@property({ reflect: true }) appearance: 'accent' | 'filled' | 'outlined' | 'plain' = 'accent';
@@ -144,6 +142,19 @@ export default class WaButton extends WebAwesomeFormAssociatedElement {
/** Used to override the form owner's `target` attribute. */
@property({ attribute: 'formtarget' }) formTarget: '_self' | '_blank' | '_parent' | '_top' | string;
private handleClick() {
const form = this.getForm();
if (!form) return;
const lightDOMButton = this.constructLightDOMButton();
// form.append(lightDOMButton);
this.parentElement?.append(lightDOMButton);
lightDOMButton.click();
lightDOMButton.remove();
}
private constructLightDOMButton() {
const button = document.createElement('button');
button.type = this.type;
@@ -167,52 +178,10 @@ export default class WaButton extends WebAwesomeFormAssociatedElement {
return button;
}
private handleClick() {
const form = this.getForm();
if (!form) return;
const lightDOMButton = this.constructLightDOMButton();
// form.append(lightDOMButton);
this.parentElement?.append(lightDOMButton);
lightDOMButton.click();
lightDOMButton.remove();
}
private handleInvalid() {
this.dispatchEvent(new WaInvalidEvent());
}
private handleLabelSlotChange() {
const nodes = this.labelSlot.assignedNodes({ flatten: true });
let hasIconLabel = false;
let hasIcon = false;
let text = '';
// If there's only an icon and no text, it's an icon button
[...nodes].forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).localName === 'wa-icon') {
hasIcon = true;
if (!hasIconLabel) hasIconLabel = (node as HTMLElement).hasAttribute('label');
}
// Concatenate text nodes
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent;
}
});
this.isIconButton = text.trim() === '' && hasIcon;
if (this.isIconButton && !hasIconLabel) {
console.warn(
'Icon buttons must have a label for screen readers. Add <wa-icon label="..."> to remove this warning.',
this,
);
}
}
private isButton() {
return this.href ? false : true;
}
@@ -265,7 +234,6 @@ export default class WaButton extends WebAwesomeFormAssociatedElement {
'has-label': this.hasSlotController.test('[default]'),
'has-prefix': this.hasSlotController.test('prefix'),
'has-suffix': this.hasSlotController.test('suffix'),
'is-icon-button': this.isIconButton,
})}
?disabled=${ifDefined(isLink ? undefined : this.disabled)}
type=${ifDefined(isLink ? undefined : this.type)}
@@ -283,7 +251,7 @@ export default class WaButton extends WebAwesomeFormAssociatedElement {
@click=${this.handleClick}
>
<slot name="prefix" part="prefix" class="prefix"></slot>
<slot part="label" class="label" @slotchange=${this.handleLabelSlotChange}></slot>
<slot part="label" class="label"></slot>
<slot name="suffix" part="suffix" class="suffix"></slot>
${
this.caret

View File

@@ -20,10 +20,6 @@
.color-picker {
background-color: var(--background-color);
border-radius: var(--border-radius);
border-style: var(--border-style);
border-width: var(--border-width);
border-color: var(--border-color);
box-shadow: var(--wa-shadow-l);
color: var(--color);
font: inherit;
user-select: none;

View File

@@ -329,6 +329,13 @@ describe('<wa-color-picker>', () => {
});
});
it('should render in a dropdown', async () => {
const el = await fixture<WaColorPicker>(html` <wa-color-picker></wa-color-picker> `);
const dropdown = el.shadowRoot!.querySelector('wa-dropdown');
expect(dropdown).to.exist;
});
it('should show opacity slider when opacity is enabled', async () => {
const el = await fixture<WaColorPicker>(html` <wa-color-picker opacity></wa-color-picker> `);
const opacitySlider = el.shadowRoot!.querySelector('[part*="opacity-slider"]')!;
@@ -361,7 +368,7 @@ describe('<wa-color-picker>', () => {
<button type="button">Click me</button>
</div>
`);
const colorPicker = el.querySelector<WaColorPicker>('wa-color-picker')!;
const colorPicker = el.querySelector('wa-color-picker')!;
const trigger = colorPicker.shadowRoot!.querySelector<HTMLButtonElement>('[part~="trigger"]')!;
const button = el.querySelector('button')!;
const focusHandler = sinon.spy();
@@ -449,7 +456,7 @@ describe('<wa-color-picker>', () => {
</form>
`);
const button = form.querySelector('wa-button')!;
const colorPicker = form.querySelector<WaColorPicker>('wa-color-picker')!;
const colorPicker = form.querySelector('wa-color-picker')!;
colorPicker.value = '#000000';
await colorPicker.updateComplete;

View File

@@ -6,9 +6,7 @@ import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
import { WaInvalidEvent } from '../../events/invalid.js';
import { animateWithClass } from '../../internal/animate.js';
import { drag } from '../../internal/drag.js';
import { waitForEvent } from '../../internal/event.js';
import { clamp } from '../../internal/math.js';
import { HasSlotController } from '../../internal/slot.js';
import { RequiredValidator } from '../../internal/validators/required-validator.js';
@@ -20,11 +18,11 @@ import visuallyHidden from '../../styles/utilities/visually-hidden.css';
import { LocalizeController } from '../../utilities/localize.js';
import '../button-group/button-group.js';
import '../button/button.js';
import '../dropdown/dropdown.js';
import type WaDropdown from '../dropdown/dropdown.js';
import '../icon/icon.js';
import '../input/input.js';
import type WaInput from '../input/input.js';
import '../popup/popup.js';
import type WaPopup from '../popup/popup.js';
import styles from './color-picker.css';
interface EyeDropperConstructor {
@@ -45,8 +43,8 @@ declare const EyeDropper: EyeDropperConstructor;
*
* @dependency wa-button
* @dependency wa-button-group
* @dependency wa-dropdown
* @dependency wa-input
* @dependency wa-popup
* @dependency wa-visually-hidden
*
* @slot label - The color picker's form label. Alternatively, you can use the `label` attribute.
@@ -127,7 +125,7 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
// or is the new behavior okay?
get validationTarget() {
// This puts the popup on the element only if the color picker is expanded.
if (this.popup?.active) {
if (this.dropdown?.open) {
return this.input;
}
@@ -136,7 +134,7 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
return this.trigger;
}
@query('.color-popup') popup: WaPopup;
@query('.color-dropdown') dropdown: WaDropdown;
@query('[part~="preview"]') previewButton: HTMLButtonElement;
@query('[part~="trigger"]') trigger: HTMLButtonElement;
@@ -212,12 +210,6 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
/** Disables the color picker. */
@property({ type: Boolean }) disabled = false;
/**
* Indicates whether or not the popup is open. You can toggle this attribute to show and hide the popup, or you
* can use the `show()` and `hide()` methods and this attribute will reflect the popup's open state.
*/
@property({ type: Boolean, reflect: true }) open = false;
/** Shows the opacity slider. Enabling this will cause the formatted value to be HEXA, RGBA, or HSLA. */
@property({ type: Boolean }) opacity = false;
@@ -798,8 +790,8 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
elementToBlur.blur();
}
if (this.popup?.active) {
this.hide();
if (this.dropdown?.open) {
this.dropdown.hide();
}
}
@@ -847,10 +839,10 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
/** Checks for validity and shows the browser's validation message if the control is invalid. */
reportValidity() {
// This won't get called when a form is submitted. This is only for manual calls.
if (!this.validity.valid && !this.open) {
// Show the popup so the browser can focus on it
if (!this.validity.valid && !this.dropdown.open) {
// Show the dropdown so the browser can focus on it
this.addEventListener('wa-after-show', this.reportValidityAfterShow, { once: true });
this.show();
this.dropdown.show();
if (!this.disabled) {
// By standards we have to emit a `wa-invalid` event here synchronously.
@@ -875,158 +867,6 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
this.hasEyeDropper = 'EyeDropper' in window;
}
private handleKeyDown = (event: KeyboardEvent) => {
// Close when escape is pressed inside an open popup. We need to listen on the panel itself and stop propagation
// in case any ancestors are also listening for this key.
if (this.open && event.key === 'Escape') {
event.stopPropagation();
this.hide();
this.focus();
}
};
private handleDocumentKeyDown = (event: KeyboardEvent) => {
// Close when escape or tab is pressed
if (event.key === 'Escape' && this.open) {
event.stopPropagation();
this.focus();
this.hide();
return;
}
// Handle tabbing
if (event.key === 'Tab') {
// Tabbing outside of the containing element closes the panel
//
// If the popup is used within a shadow DOM, we need to obtain the activeElement within that shadowRoot,
// otherwise `document.activeElement` will only return the name of the parent shadow DOM element.
setTimeout(() => {
const activeElement =
this.getRootNode() instanceof ShadowRoot
? document.activeElement?.shadowRoot?.activeElement
: document.activeElement;
if (!this || activeElement?.closest(this.tagName.toLowerCase()) !== this) {
this.hide();
}
});
}
};
private handleDocumentMouseDown = (event: MouseEvent) => {
// Close when clicking outside of the popup panel and trigger
const path = event.composedPath();
// Check if click is inside the popup panel or the trigger element specifically
const isInsideRelevantArea = path.some(
element => element instanceof Element && (element.closest('.color-picker') || element === this.trigger),
);
if (this && !isInsideRelevantArea) {
this.hide();
}
};
handleTriggerClick() {
if (this.open) {
this.hide();
} else {
this.show();
this.focus();
}
}
async handleTriggerKeyDown(event: KeyboardEvent) {
// When spacebar/enter is pressed, show the panel but don't focus on the menu. This let's the user press the same
// key again to hide the menu in case they don't want to make a selection.
if ([' ', 'Enter'].includes(event.key)) {
event.preventDefault();
this.handleTriggerClick();
return;
}
}
handleTriggerKeyUp(event: KeyboardEvent) {
// Prevent space from triggering a click event in Firefox
if (event.key === ' ') {
event.preventDefault();
}
}
updateAccessibleTrigger() {
const accessibleTrigger = this.trigger;
if (accessibleTrigger) {
accessibleTrigger.setAttribute('aria-haspopup', 'true');
accessibleTrigger.setAttribute('aria-expanded', this.open ? 'true' : 'false');
}
}
/** Shows the color picker panel. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'wa-after-show');
}
/** Hides the color picker panel */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'wa-after-hide');
}
addOpenListeners() {
this.base.addEventListener('keydown', this.handleKeyDown);
document.addEventListener('keydown', this.handleDocumentKeyDown);
document.addEventListener('mousedown', this.handleDocumentMouseDown);
}
removeOpenListeners() {
if (this.base) {
this.base.removeEventListener('keydown', this.handleKeyDown);
}
document.removeEventListener('keydown', this.handleDocumentKeyDown);
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
}
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.disabled) {
this.open = false;
return;
}
this.updateAccessibleTrigger();
if (this.open) {
// Show
this.dispatchEvent(new CustomEvent('wa-show'));
this.addOpenListeners();
await this.updateComplete;
this.base.hidden = false;
this.popup.active = true;
await animateWithClass(this.popup.popup, 'show-with-scale');
this.dispatchEvent(new CustomEvent('wa-after-show'));
} else {
// Hide
this.dispatchEvent(new CustomEvent('wa-hide'));
this.removeOpenListeners();
await animateWithClass(this.popup.popup, 'hide-with-scale');
this.base.hidden = true;
this.popup.active = false;
this.dispatchEvent(new CustomEvent('wa-after-hide'));
}
}
render() {
const hasLabelSlot = !this.hasUpdated ? this.withLabel : this.withLabel || this.hasSlotController.test('label');
const hasHintSlot = !this.hasUpdated ? this.withHint : this.withHint || this.hasSlotController.test('hint');
@@ -1255,64 +1095,82 @@ export default class WaColorPicker extends WebAwesomeFormAssociatedElement {
</div>
`;
// Render with popup
// Render as a dropdown
return html`
<div
class=${classMap({
container: true,
'form-control': true,
'form-control-has-label': hasLabel,
})}
part="trigger-container form-control"
>
<div part="form-control-label" class="label" id="form-control-label">
<slot name="label">${this.label}</slot>
</div>
<button
id="trigger"
part="trigger form-control-input"
class=${classMap({
trigger: true,
'trigger-empty': this.isEmpty,
'transparent-bg': true,
'form-control-input': true,
})}
style=${styleMap({
color: this.getHexString(this.hue, this.saturation, this.brightness, this.alpha),
})}
type="button"
aria-labelledby="form-control-label"
aria-describedby="hint"
.disabled=${this.disabled}
@click=${this.handleTriggerClick}
@keydown=${this.handleTriggerKeyDown}
@keyup=${this.handleTriggerKeyUp}
></button>
<slot
name="hint"
part="hint"
class=${classMap({
'has-slotted': hasHint,
})}
>${this.hint}</slot
>
</div>
<wa-popup
class="color-popup"
anchor="trigger"
placement="bottom-start"
distance="0"
skidding="0"
sync="width"
<wa-dropdown
class="color-dropdown"
aria-disabled=${this.disabled ? 'true' : 'false'}
.containingElement=${this}
?disabled=${this.disabled}
@wa-after-show=${this.handleAfterShow}
@wa-after-hide=${this.handleAfterHide}
>
<div
class=${classMap({
container: true,
'form-control': true,
'form-control-has-label': hasLabel,
})}
part="trigger-container form-control"
slot="trigger"
@click=${(e: Event) => {
const composedPath = e.composedPath();
const triggerButton = this.triggerButton;
const triggerLabel = this.triggerLabel;
const buttonOrLabelClicked = composedPath.find(el => el === triggerButton || el === triggerLabel);
if (buttonOrLabelClicked) {
return;
}
// Stop clicks from bubbling on anything except the button and the label. This is a hacky work around i may come to regret, but this "fixes" the issue of `<wa-dropdown>` expecting all children in the "trigger slot" to open the trigger. [Konnor]
e.stopImmediatePropagation();
e.stopPropagation();
if (this.dropdown.open) {
this.dropdown.hide();
}
}}
>
<div part="form-control-label" class="label" id="form-control-label">
<slot name="label">${this.label}</slot>
</div>
<button
id="trigger"
part="trigger form-control-input"
class=${classMap({
trigger: true,
'trigger-empty': this.isEmpty,
'transparent-bg': true,
'form-control-input': true,
})}
style=${styleMap({
color: this.getHexString(this.hue, this.saturation, this.brightness, this.alpha),
})}
type="button"
aria-labelledby="form-control-label"
aria-describedby="hint"
.disabled=${this.disabled}
></button>
<slot
name="hint"
part="hint"
class=${classMap({
'has-slotted': hasHint,
})}
>${this.hint}</slot
>
</div>
${colorPicker}
</wa-popup>
</wa-dropdown>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'wa-color-picker': WaColorPicker;
}
}

View File

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

View File

@@ -72,13 +72,8 @@
flex: 0 0 auto;
display: flex;
flex-wrap: nowrap;
padding-inline-start: var(--spacing);
padding: var(--spacing);
padding-block-end: 0;
/* Subtract the close button's padding so that the X is visually aligned with the edges of the dialog content */
padding-inline-end: calc(var(--spacing) - var(--wa-form-control-padding-block));
padding-block-start: calc(var(--spacing) - var(--wa-form-control-padding-block));
}
.title {
@@ -101,11 +96,12 @@
padding-inline-start: var(--spacing);
}
.header-actions wa-button,
.header-actions ::slotted(wa-button) {
.header-actions wa-icon-button,
.header-actions ::slotted(wa-icon-button) {
flex: 0 0 auto;
display: flex;
align-items: center;
font-size: var(--wa-font-size-m);
}
.body {

View File

@@ -12,7 +12,7 @@ import { HasSlotController } from '../../internal/slot.js';
import { watch } from '../../internal/watch.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import { LocalizeController } from '../../utilities/localize.js';
import '../button/button.js';
import '../icon-button/icon-button.js';
import styles from './dialog.css';
/**
@@ -21,11 +21,11 @@ import styles from './dialog.css';
* @status stable
* @since 2.0
*
* @dependency wa-button
* @dependency wa-icon-button
*
* @slot - The dialog's main content.
* @slot label - The dialog's label. Alternatively, you can use the `label` attribute.
* @slot header-actions - Optional actions to add to the header. Works best with `<wa-button>`.
* @slot header-actions - Optional actions to add to the header. Works best with `<wa-icon-button>`.
* @slot footer - The dialog's footer, usually one or more buttons representing various options.
*
* @event wa-show - Emitted when the dialog opens.
@@ -38,9 +38,9 @@ import styles from './dialog.css';
* @event wa-after-hide - Emitted after the dialog closes and all animations are complete.
*
* @csspart header - The dialog's header. This element wraps the title and header actions.
* @csspart header-actions - Optional actions to add to the header. Works best with `<wa-button>`.
* @csspart header-actions - Optional actions to add to the header. Works best with `<wa-icon-button>`.
* @csspart title - The dialog's title.
* @csspart close-button - The close button, a `<wa-button>`.
* @csspart close-button - The close button, a `<wa-icon-button>`.
* @csspart close-button__base - The close button's exported `base` part.
* @csspart body - The dialog's body.
* @csspart footer - The dialog's footer.
@@ -63,7 +63,10 @@ export default class WaDialog extends WebAwesomeElement {
@query('.dialog') dialog: HTMLDialogElement;
/** Indicates whether or not the dialog is open. Toggle this attribute to show and hide the dialog. */
/**
* 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.
*/
@property({ type: Boolean, reflect: true }) open = false;
/**
@@ -233,20 +236,16 @@ export default class WaDialog extends WebAwesomeElement {
</h2>
<div part="header-actions" class="header-actions">
<slot name="header-actions"></slot>
<wa-button
<wa-icon-button
part="close-button"
exportparts="base:close-button__base"
class="close"
appearance="plain"
name="xmark"
label=${this.localize.term('close')}
library="system"
variant="solid"
@click="${(event: PointerEvent) => this.requestClose(event.target as Element)}"
>
<wa-icon
name="xmark"
label=${this.localize.term('close')}
library="system"
variant="solid"
></wa-icon>
</wa-button>
></wa-icon-button>
</div>
</header>
`

View File

@@ -136,12 +136,8 @@
.header {
display: flex;
flex-wrap: nowrap;
padding-inline-start: var(--spacing);
padding: var(--spacing);
padding-block-end: 0;
/* Subtract the close button's padding so that the X is visually aligned with the edges of the dialog content */
padding-inline-end: calc(var(--spacing) - var(--wa-form-control-padding-block));
padding-block-start: calc(var(--spacing) - var(--wa-form-control-padding-block));
}
.title {
@@ -164,11 +160,12 @@
padding-inline-start: var(--spacing);
}
.header-actions wa-button,
.header-actions ::slotted(wa-button) {
.header-actions wa-icon-button,
.header-actions ::slotted(wa-icon-button) {
flex: 0 0 auto;
display: flex;
align-items: center;
font-size: var(--wa-font-size-m);
}
.body {

View File

@@ -12,7 +12,7 @@ import { HasSlotController } from '../../internal/slot.js';
import { watch } from '../../internal/watch.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import { LocalizeController } from '../../utilities/localize.js';
import '../button/button.js';
import '../icon-button/icon-button.js';
import styles from './drawer.css';
/**
@@ -21,11 +21,11 @@ import styles from './drawer.css';
* @status stable
* @since 2.0
*
* @dependency wa-button
* @dependency wa-icon-button
*
* @slot - The drawer's main content.
* @slot label - The drawer's label. Alternatively, you can use the `label` attribute.
* @slot header-actions - Optional actions to add to the header. Works best with `<wa-button>`.
* @slot header-actions - Optional actions to add to the header. Works best with `<wa-icon-button>`.
* @slot footer - The drawer's footer, usually one or more buttons representing various options.
*
* @event wa-show - Emitted when the drawer opens.
@@ -39,9 +39,9 @@ import styles from './drawer.css';
* behavior such as data loss.
*
* @csspart header - The drawer's header. This element wraps the title and header actions.
* @csspart header-actions - Optional actions to add to the header. Works best with `<wa-button>`.
* @csspart header-actions - Optional actions to add to the header. Works best with `<wa-icon-button>`.
* @csspart title - The drawer's title.
* @csspart close-button - The close button, a `<wa-button>`.
* @csspart close-button - The close button, a `<wa-icon-button>`.
* @csspart close-button__base - The close button's exported `base` part.
* @csspart body - The drawer's body.
* @csspart footer - The drawer's footer.
@@ -68,7 +68,10 @@ export default class WaDrawer extends WebAwesomeElement {
@query('.drawer') drawer: HTMLDialogElement;
/** Indicates whether or not the drawer is open. Toggle this attribute to show and hide the drawer. */
/**
* 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.
*/
@property({ type: Boolean, reflect: true }) open = false;
/**
@@ -249,20 +252,16 @@ export default class WaDrawer extends WebAwesomeElement {
</h2>
<div part="header-actions" class="header-actions">
<slot name="header-actions"></slot>
<wa-button
<wa-icon-button
part="close-button"
exportparts="base:close-button__base"
class="close"
appearance="plain"
name="xmark"
label=${this.localize.term('close')}
library="system"
variant="solid"
@click="${(event: PointerEvent) => this.requestClose(event.target as Element)}"
>
<wa-icon
name="xmark"
label=${this.localize.term('close')}
library="system"
variant="solid"
></wa-icon>
</wa-button>
></wa-icon-button>
</div>
</header>
`

View File

@@ -1,243 +0,0 @@
:host {
display: flex;
position: relative;
align-items: center;
padding: 0.33em 1em;
border-radius: var(--wa-border-radius-s);
isolation: isolate;
color: var(--wa-color-neutral-on-quiet);
line-height: var(--wa-line-height-normal);
cursor: pointer;
transition:
100ms background-color ease,
100ms color ease;
}
@media (hover: hover) {
:host(:hover:not(:state(disabled))) {
background-color: var(--wa-color-neutral-fill-quiet);
color: var(--wa-color-neutral-on-quiet);
}
}
:host(:focus-visible) {
z-index: 1;
outline: var(--wa-color-brand-border-loud);
outline-offset: var(--wa-focus-ring-offset);
background-color: var(--wa-color-neutral-fill-quiet);
color: var(--wa-color-neutral-on-quiet);
}
:host(:state(disabled)) {
opacity: 0.5;
cursor: not-allowed;
}
/* Sizes */
:host([size='small']) {
font-size: var(--wa-font-size-s);
}
:host([size='medium']) {
font-size: var(--wa-font-size-m);
}
:host([size='large']) {
font-size: var(--wa-font-size-l);
}
/* Danger variant */
:host([variant='danger']),
:host([variant='danger']) #details {
color: var(--wa-color-danger-on-quiet);
}
@media (hover: hover) {
:host([variant='danger']:hover) {
background-color: var(--wa-color-danger-fill-quiet);
color: var(--wa-color-danger-on-quiet);
}
}
:host([variant='danger']:focus-visible) {
background-color: var(--wa-color-danger-fill-quiet);
color: var(--wa-color-danger-on-quiet);
}
:host([checkbox-adjacent]) {
padding-inline-start: 2em;
}
/* Only add padding when item actually has a submenu */
:host([submenu-adjacent]:not(:state(has-submenu))) #details {
padding-inline-end: 0;
}
:host(:state(has-submenu)[submenu-adjacent]) #details {
padding-inline-end: 1.75em;
}
#check {
visibility: hidden;
margin-inline-start: -1.25em;
margin-inline-end: 0.25em;
font-size: 1.25em;
}
:host(:state(checked)) #check {
visibility: visible;
}
#icon ::slotted(*) {
display: flex;
flex: 0 0 auto;
align-items: center;
margin-inline-end: 0.5em !important;
font-size: 1.25em;
}
#label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#details {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: end;
color: var(--wa-color-neutral-border-normal);
font-size: 0.933334em !important;
}
#details ::slotted(*) {
margin-inline-start: 2em !important;
}
/* Submenu indicator icon */
#submenu-indicator {
position: absolute;
inset-inline-end: 0.25em;
color: var(--wa-color-neutral-border-normal);
font-size: 1.25em;
}
/* Flip chevron icon when RTL */
:host(:dir(rtl)) #submenu-indicator {
transform: scaleX(-1);
}
/* Submenu styles */
#submenu {
display: flex;
z-index: 10;
position: absolute;
top: 0;
left: 0;
flex-direction: column;
width: max-content;
margin: 0;
padding: 0.25em;
border: var(--wa-border-style) var(--wa-border-width-s) var(--wa-color-neutral-border-quiet);
border-radius: var(--wa-border-radius-m);
background-color: var(--wa-color-surface-default);
box-shadow: var(--wa-shadow-l);
color: var(--wa-color-neutral-on-quiet);
text-align: start;
user-select: none;
/* Override default popover styles */
&[popover] {
margin: 0;
inset: auto;
padding: 0.25em;
overflow: visible;
border-radius: var(--wa-border-radius-m);
}
&.show {
animation: submenu-show var(--show-duration, 50ms) ease;
}
&.hide {
animation: submenu-show var(--show-duration, 50ms) ease reverse;
}
/* Submenu placement transform origins */
&[data-placement^='top'] {
transform-origin: bottom;
}
&[data-placement^='bottom'] {
transform-origin: top;
}
&[data-placement^='left'] {
transform-origin: right;
}
&[data-placement^='right'] {
transform-origin: left;
}
&[data-placement='left-start'] {
transform-origin: right top;
}
&[data-placement='left-end'] {
transform-origin: right bottom;
}
&[data-placement='right-start'] {
transform-origin: left top;
}
&[data-placement='right-end'] {
transform-origin: left bottom;
}
/* Safe triangle styling */
&::before {
display: none;
z-index: 9;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: transparent;
content: '';
clip-path: polygon(
var(--safe-triangle-cursor-x, 0) var(--safe-triangle-cursor-y, 0),
var(--safe-triangle-submenu-start-x, 0) var(--safe-triangle-submenu-start-y, 0),
var(--safe-triangle-submenu-end-x, 0) var(--safe-triangle-submenu-end-y, 0)
);
pointer-events: auto; /* Enable mouse events on the triangle */
}
&[data-visible]::before {
display: block;
}
}
::slotted(wa-dropdown-item) {
font-size: inherit;
}
::slotted(wa-divider) {
--spacing: 0.25em;
}
@keyframes submenu-show {
from {
scale: 0.9;
opacity: 0;
}
to {
scale: 1;
opacity: 1;
}
}

View File

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

View File

@@ -1,302 +0,0 @@
import type { PropertyValues } from 'lit';
import { html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { animateWithClass } from '../../internal/animate.js';
import { HasSlotController } from '../../internal/slot.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import styles from './dropdown-item.css';
/**
* @summary Represents an individual item within a dropdown menu, supporting standard items, checkboxes, and submenus.
* @documentation https://backers.webawesome.com/docs/components/dropdown-item
* @status experimental
* @since 3.0
*
* @dependency wa-icon
*
* @event blur - Emitted when the dropdown item loses focus.
* @event focus - Emitted when the dropdown item gains focus.
*
* @slot - The dropdown item's label.
* @slot icon - An optional icon to display before the label.
* @slot details - Additional content or details to display after the label.
* @slot submenu - Submenu items, typically `<wa-dropdown-item>` elements, to create a nested menu.
*
* @csspart checkmark - The checkmark icon (a `<wa-icon>` element) when the item is a checkbox.
* @csspart icon - The container for the icon slot.
* @csspart label - The container for the label slot.
* @csspart details - The container for the details slot.
* @csspart submenu-icon - The submenu indicator icon (a `<wa-icon>` element).
* @csspart submenu - The submenu container.
*/
@customElement('wa-dropdown-item')
export default class WaDropdownItem extends WebAwesomeElement {
static css = styles;
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');
@query('#submenu') submenuElement: HTMLDivElement;
/** @internal The controller will set this property to true when the item is active. */
@property({ type: Boolean }) active = false;
/** The type of menu item to render. */
@property({ reflect: true }) variant: 'danger' | 'default' = 'default';
/**
* @internal The dropdown item's size.
*/
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
/**
* @internal The controller will set this property to true when at least one checkbox exists in the dropdown. This
* allows non-checkbox items to draw additional space to align properly with checkbox items.
*/
@property({ attribute: 'checkbox-adjacent', type: Boolean, reflect: true }) checkboxAdjacent = false;
/**
* @internal The controller will set this property to true when at least one item with a submenu exists in the
* dropdown. This allows non-submenu items to draw additional space to align properly with items that have submenus.
*/
@property({ attribute: 'submenu-adjacent', type: Boolean, reflect: true }) submenuAdjacent = false;
/**
* An optional value for the menu item. This is useful for determining which item was selected when listening to the
* dropdown's `wa-select` event.
*/
@property() value: string;
/** Set to `checkbox` to make the item a checkbox. */
@property({ reflect: true }) type: 'normal' | 'checkbox' = 'normal';
/** Set to true to check the dropdown item. Only valid when `type` is `checkbox`. */
@property({ type: Boolean }) checked = false;
/** Disables the dropdown item. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** Whether the submenu is currently open. */
@property({ type: Boolean, reflect: true }) submenuOpen = false;
/** @internal Store whether this item has a submenu */
@state() hasSubmenu = false;
connectedCallback() {
super.connectedCallback();
this.addEventListener('mouseenter', this.handleMouseEnter.bind(this));
this.shadowRoot!.addEventListener('slotchange', this.handleSlotChange);
}
disconnectedCallback() {
super.disconnectedCallback();
this.closeSubmenu();
this.removeEventListener('mouseenter', this.handleMouseEnter);
this.shadowRoot!.removeEventListener('slotchange', this.handleSlotChange);
}
firstUpdated() {
this.setAttribute('tabindex', '-1');
this.hasSubmenu = this.hasSlotController.test('submenu');
this.updateHasSubmenuState();
}
updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has('active')) {
this.setAttribute('tabindex', this.active ? '0' : '-1');
this.customStates.set('active', this.active);
}
if (changedProperties.has('checked')) {
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
this.customStates.set('checked', this.checked);
}
if (changedProperties.has('disabled')) {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
this.customStates.set('disabled', this.disabled);
}
if (changedProperties.has('type')) {
if (this.type === 'checkbox') {
this.setAttribute('role', 'menuitemcheckbox');
} else {
this.setAttribute('role', 'menuitem');
}
}
if (changedProperties.has('submenuOpen')) {
this.customStates.set('submenu-open', this.submenuOpen);
if (this.submenuOpen) {
this.openSubmenu();
} else {
this.closeSubmenu();
}
}
}
private handleSlotChange = () => {
this.hasSubmenu = this.hasSlotController.test('submenu');
this.updateHasSubmenuState();
if (this.hasSubmenu) {
this.setAttribute('aria-haspopup', 'menu');
this.setAttribute('aria-expanded', this.submenuOpen ? 'true' : 'false');
} else {
this.removeAttribute('aria-haspopup');
this.removeAttribute('aria-expanded');
}
};
/** Update the has-submenu custom state */
private updateHasSubmenuState() {
this.customStates.set('has-submenu', this.hasSubmenu);
}
/** Opens the submenu. */
async openSubmenu() {
if (!this.hasSubmenu || !this.submenuElement) return;
// Notify parent dropdown to handle positioning
this.notifyParentOfOpening();
// Use Popover API to show the submenu
this.submenuElement.showPopover();
this.submenuElement.hidden = false;
this.submenuElement.setAttribute('data-visible', '');
this.submenuOpen = true;
this.setAttribute('aria-expanded', 'true');
// Animate the submenu
await animateWithClass(this.submenuElement, 'show');
// Set focus to the first submenu item
setTimeout(() => {
const items = this.getSubmenuItems();
if (items.length > 0) {
items.forEach((item, index) => (item.active = index === 0));
items[0].focus();
}
}, 0);
}
/** Notifies the parent dropdown that this item is opening its submenu */
private notifyParentOfOpening() {
// First notify the parent that we're about to open
const event = new CustomEvent('submenu-opening', {
bubbles: true,
composed: true,
detail: { item: this },
});
this.dispatchEvent(event);
// Find sibling items that have open submenus and close them
const parent = this.parentElement;
if (parent) {
const siblings = [...parent.children].filter(
el =>
el !== this &&
el.localName === 'wa-dropdown-item' &&
el.getAttribute('slot') === this.getAttribute('slot') &&
(el as WaDropdownItem).submenuOpen,
) as WaDropdownItem[];
// Close each sibling submenu with animation
siblings.forEach(sibling => {
sibling.submenuOpen = false;
});
}
}
/** Closes the submenu. */
async closeSubmenu() {
if (!this.hasSubmenu || !this.submenuElement) return;
this.submenuOpen = false;
this.setAttribute('aria-expanded', 'false');
if (!this.submenuElement.hidden) {
await animateWithClass(this.submenuElement, 'hide');
this.submenuElement.hidden = true;
this.submenuElement.removeAttribute('data-visible');
this.submenuElement.hidePopover();
}
}
/** Gets all dropdown items in the submenu. */
private getSubmenuItems(): WaDropdownItem[] {
// Only get direct children with slot="submenu", not nested ones
return [...this.children].filter(
el =>
el.localName === 'wa-dropdown-item' && el.getAttribute('slot') === 'submenu' && !el.hasAttribute('disabled'),
) as WaDropdownItem[];
}
/** Handles mouse enter to open the submenu */
private handleMouseEnter() {
if (this.hasSubmenu && !this.disabled) {
this.notifyParentOfOpening();
this.submenuOpen = true;
}
}
render() {
return html`
${this.type === 'checkbox'
? html`
<wa-icon
id="check"
part="checkmark"
exportparts="svg:checkmark__svg"
library="system"
name="check"
></wa-icon>
`
: ''}
<span id="icon" part="icon">
<slot name="icon"></slot>
</span>
<span id="label" part="label">
<slot></slot>
</span>
<span id="details" part="details">
<slot name="details"></slot>
</span>
${this.hasSubmenu
? html`
<wa-icon
id="submenu-indicator"
part="submenu-icon"
exportparts="svg:submenu-icon__svg"
library="system"
name="chevron-right"
></wa-icon>
`
: ''}
${this.hasSubmenu
? html`
<div
id="submenu"
part="submenu"
popover="manual"
role="menu"
tabindex="-1"
aria-orientation="vertical"
hidden
>
<slot name="submenu"></slot>
</div>
`
: ''}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'wa-dropdown-item': WaDropdownItem;
}
}

View File

@@ -1,92 +1,60 @@
:host {
--show-duration: 50ms;
--hide-duration: 50ms;
display: contents;
--box-shadow: var(--wa-shadow-m);
display: inline-block;
}
#menu {
display: flex;
position: absolute;
top: 0;
left: 0;
flex-direction: column;
width: max-content;
margin: 0;
padding: 0.25em;
border: var(--wa-border-style) var(--wa-border-width-s) var(--wa-color-neutral-border-quiet);
border-radius: var(--wa-border-radius-m);
background-color: var(--wa-color-surface-default);
box-shadow: var(--wa-shadow-m);
color: var(--wa-color-neutral-on-quiet);
text-align: start;
user-select: none;
&.show {
animation: show var(--show-duration) ease;
}
&.hide {
animation: show var(--hide-duration) ease reverse;
}
::slotted(h1),
::slotted(h2),
::slotted(h3),
::slotted(h4),
::slotted(h5),
::slotted(h6) {
display: block !important;
margin: 0.25em 0 !important;
padding: 0.25em 1em !important;
color: var(--wa-color-text-quiet) !important;
font-weight: var(--wa-font-weight-semibold) !important;
font-size: 0.75em !important;
}
::slotted(wa-divider) {
--spacing: 0.25em; /* Component-specific, left as-is */
}
.dropdown::part(popup) {
z-index: 900;
}
wa-popup[data-current-placement^='top'] #menu {
.dropdown[data-current-placement^='top']::part(popup) {
transform-origin: bottom;
}
wa-popup[data-current-placement^='bottom'] #menu {
.dropdown[data-current-placement^='bottom']::part(popup) {
transform-origin: top;
}
wa-popup[data-current-placement^='left'] #menu {
.dropdown[data-current-placement^='left']::part(popup) {
transform-origin: right;
}
wa-popup[data-current-placement^='right'] #menu {
.dropdown[data-current-placement^='right']::part(popup) {
transform-origin: left;
}
wa-popup[data-current-placement='left-start'] #menu {
transform-origin: right top;
#trigger {
display: block; /* for boundingClientRect */
}
wa-popup[data-current-placement='left-end'] #menu {
transform-origin: right bottom;
.panel {
font: inherit;
box-shadow: var(--box-shadow);
border-radius: var(--wa-border-radius-m);
pointer-events: none;
}
wa-popup[data-current-placement='right-start'] #menu {
transform-origin: left top;
.dropdown-open .panel {
display: block;
pointer-events: all;
}
wa-popup[data-current-placement='right-end'] #menu {
transform-origin: left bottom;
/* Sizes */
:host([size='small']) ::slotted(wa-menu) {
font-size: var(--wa-font-size-s);
}
@keyframes show {
from {
scale: 0.9;
opacity: 0;
}
to {
scale: 1;
opacity: 1;
}
:host([size='medium']) ::slotted(wa-menu) {
font-size: var(--wa-font-size-m);
}
:host([size='large']) ::slotted(wa-menu) {
font-size: var(--wa-font-size-l);
}
/* When users slot a menu, make sure it conforms to the popup's auto-size */
::slotted(wa-menu) {
max-width: var(--auto-size-available-width) !important;
max-height: var(--auto-size-available-height) !important;
}

View File

@@ -1,9 +1,405 @@
import { expect, fixture, html } from '@open-wc/testing';
import { expect, waitUntil } from '@open-wc/testing';
import { sendKeys, sendMouse } from '@web/test-runner-commands';
import { html } from 'lit';
import sinon from 'sinon';
import { clickOnElement } from '../../internal/test.js';
import { fixtures } from '../../internal/test/fixture.js';
import type WaDropdown from './dropdown.js';
describe('<wa-dropdown>', () => {
it('should render a component', async () => {
const el = await fixture(html` <wa-dropdown></wa-dropdown> `);
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should be visible with the open attribute', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
expect(el).to.exist;
});
expect(panel.hidden).to.be.false;
});
it('should not be visible without the open attribute', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
expect(panel.hidden).to.be.true;
});
it('should emit wa-show and wa-after-show when calling show()', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-after-show', afterShowHandler);
el.show();
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.false;
});
it('should emit wa-hide and wa-after-hide when calling hide()', async () => {
// @TODO: Fix this [Konnor]
if (fixture.type === 'ssr-client-hydrated') {
return;
}
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.hide();
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.true;
});
it('should emit wa-show and wa-after-show when setting open = true', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
const showHandler = sinon.spy();
const afterShowHandler = sinon.spy();
el.addEventListener('wa-show', showHandler);
el.addEventListener('wa-after-show', afterShowHandler);
el.open = true;
await waitUntil(() => showHandler.calledOnce);
await waitUntil(() => afterShowHandler.calledOnce);
expect(showHandler).to.have.been.calledOnce;
expect(afterShowHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.false;
});
it('should emit wa-hide and wa-after-hide when setting open = false', async () => {
// @TODO: Fix this [Konnor]
if (fixture.type === 'ssr-client-hydrated') {
return;
}
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const panel = el.shadowRoot!.querySelector<HTMLElement>('[part~="panel"]')!;
const hideHandler = sinon.spy();
const afterHideHandler = sinon.spy();
el.addEventListener('wa-hide', hideHandler);
el.addEventListener('wa-after-hide', afterHideHandler);
el.open = false;
await waitUntil(() => hideHandler.calledOnce);
await waitUntil(() => afterHideHandler.calledOnce);
expect(hideHandler).to.have.been.calledOnce;
expect(afterHideHandler).to.have.been.calledOnce;
expect(panel.hidden).to.be.true;
});
it('should still open on arrow navigation when no menu items', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu> </wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
trigger.focus();
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
expect(el.open).to.be.true;
});
it('should open on arrow down navigation', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
const firstMenuItem = el.querySelectorAll('wa-menu-item')[0];
trigger.focus();
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
expect(el.open).to.be.true;
expect(document.activeElement).to.equal(firstMenuItem);
});
it('should open on arrow up navigation', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
const secondMenuItem = el.querySelectorAll('wa-menu-item')[1];
trigger.focus();
await sendKeys({ press: 'ArrowUp' });
await el.updateComplete;
expect(el.open).to.be.true;
expect(document.activeElement).to.equal(secondMenuItem);
});
it('should navigate to first focusable item on arrow navigation', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-label>Top Label</wa-menu-label>
<wa-menu-item>Item 1</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
const item = el.querySelector('wa-menu-item')!;
await clickOnElement(trigger);
await trigger.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
expect(document.activeElement).to.equal(item);
});
it('should close on escape key', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
trigger.focus();
await sendKeys({ press: 'Escape' });
await el.updateComplete;
expect(el.open).to.be.false;
});
it('should not open on arrow navigation when no menu exists', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<div>Some custom content</div>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
trigger.focus();
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
expect(el.open).to.be.false;
});
it('should open on enter key', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
trigger.focus();
await el.updateComplete;
await sendKeys({ press: 'Enter' });
await el.updateComplete;
expect(el.open).to.be.true;
});
it('should focus on menu items when clicking the trigger and arrowing through options', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
const secondMenuItem = el.querySelectorAll('wa-menu-item')[1];
await clickOnElement(trigger);
await trigger.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await el.updateComplete;
expect(document.activeElement).to.equal(secondMenuItem);
});
it('should open on enter key when no menu exists', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<div>Some custom content</div>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
trigger.focus();
await el.updateComplete;
await sendKeys({ press: 'Enter' });
await el.updateComplete;
expect(el.open).to.be.true;
});
it('should hide when clicked outside container and initially open', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
await sendMouse({ type: 'click', position: [0, 0] });
await el.updateComplete;
expect(el.open).to.be.false;
});
it('should hide when clicked outside container', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const trigger = el.querySelector('wa-button')!;
trigger.click();
await el.updateComplete;
await sendMouse({ type: 'click', position: [0, 0] });
await el.updateComplete;
expect(el.open).to.be.false;
});
it('should close and stop propagating the keydown event when Escape is pressed and the dropdown is open ', async () => {
const el = await fixture<WaDropdown>(html`
<wa-dropdown open>
<wa-button slot="trigger" caret>Toggle</wa-button>
<wa-menu>
<wa-menu-item>Dropdown Item 1</wa-menu-item>
<wa-menu-item>Dropdown Item 2</wa-menu-item>
<wa-menu-item>Dropdown Item 3</wa-menu-item>
</wa-menu>
</wa-dropdown>
`);
const firstMenuItem = el.querySelector('wa-menu-item')!;
const hideHandler = sinon.spy();
document.body.addEventListener('keydown', hideHandler);
firstMenuItem.focus();
await sendKeys({ press: 'Escape' });
await el.updateComplete;
expect(el.open).to.be.false;
if ('CloseWatcher' in window) {
return;
}
// @TODO: Fix this [Konnor]
if (fixture.type === 'ssr-client-hydrated') {
return;
}
expect(hideHandler).to.not.have.been.called;
});
});
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
:host {
--background-color-hover: var(--wa-color-neutral-fill-quiet);
--text-color-hover: color-mix(in oklab, currentColor, var(--wa-color-mix-hover));
--background-color-active: transparent;
--text-color-active: color-mix(in oklab, currentColor, var(--wa-color-mix-active));
display: inline-block;
color: var(--wa-color-text-quiet);
}
.icon-button {
flex: 0 0 auto;
display: flex;
align-items: center;
background: none;
border: none;
border-radius: var(--wa-border-radius-m);
font-size: inherit;
color: inherit;
padding: 0.5em;
cursor: pointer;
transition: color var(--wa-transition-fast) var(--wa-transition-easing);
-webkit-appearance: none;
}
:host(:not([disabled])) .icon-button:hover,
:host(:not([disabled])) .icon-button:focus-visible {
background-color: var(--background-color-hover);
color: var(--text-color-hover);
}
:host(:not([disabled])) .icon-button:active {
background-color: var(--background-color-active);
color: var(--text-color-active);
}
.icon-button:focus {
outline: none;
}
:host([disabled]) .icon-button {
opacity: 0.5;
cursor: not-allowed;
}
.icon-button:focus-visible {
outline: var(--wa-focus-ring);
outline-offset: var(--wa-focus-ring-offset);
}
.icon {
pointer-events: none;
}

View File

@@ -0,0 +1,180 @@
import { expect, waitUntil } from '@open-wc/testing';
import { html } from 'lit';
import sinon from 'sinon';
import { fixtures } from '../../internal/test/fixture.js';
import type WaIconButton from './icon-button.js';
type LinkTarget = '_self' | '_blank' | '_parent' | '_top';
describe('<wa-icon-button>', () => {
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
describe('defaults ', () => {
it('default properties', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button></wa-icon-button> `);
expect(el.name).to.be.null;
expect(el.library).to.be.undefined;
expect(el.src).to.be.undefined;
expect(el.href).to.be.undefined;
expect(el.target).to.be.undefined;
expect(el.download).to.be.undefined;
expect(el.label).to.equal('');
expect(el.disabled).to.equal(false);
});
it('renders as a button by default', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button></wa-icon-button> `);
expect(el.shadowRoot?.querySelector('button')).to.exist;
expect(el.shadowRoot?.querySelector('a')).not.to.exist;
});
});
describe('when styling the host element', () => {
it('renders the correct color and font size', async () => {
const el = await fixture<WaIconButton>(html`
<wa-icon-button
library="system"
name="check"
style="color: rgb(0, 136, 221); font-size: 2rem;"
></wa-icon-button>
`);
const icon = el.shadowRoot!.querySelector('wa-icon')!;
const styles = getComputedStyle(icon);
expect(styles.color).to.equal('rgb(0, 136, 221)');
expect(styles.fontSize).to.equal('32px');
});
});
describe('when icon attributes are present', () => {
it('renders an wa-icon from a library', async () => {
const el = await fixture<WaIconButton>(html`
<wa-icon-button library="system" name="check"></wa-icon-button>
`);
expect(el.shadowRoot?.querySelector('wa-icon')).to.exist;
});
it('renders an wa-icon from a src', async () => {
const fakeId = 'test-src';
const el = await fixture<WaIconButton>(html` <wa-icon-button></wa-icon-button> `);
el.src = `data:image/svg+xml,${encodeURIComponent(`<svg id="${fakeId}"></svg>`)}`;
await el.updateComplete;
const internalWaIcon = el.shadowRoot?.querySelector('wa-icon');
await waitUntil(() => internalWaIcon?.shadowRoot?.querySelector('svg'), 'SVG not rendered');
expect(internalWaIcon).to.exist;
expect(internalWaIcon?.shadowRoot?.querySelector('svg')).to.exist;
expect(internalWaIcon?.shadowRoot?.querySelector('svg')?.getAttribute('id')).to.equal(fakeId);
});
});
describe('when href is present', () => {
it('renders as an anchor', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button href="some/path"></wa-icon-button> `);
expect(el.shadowRoot?.querySelector('a')).to.exist;
expect(el.shadowRoot?.querySelector('button')).not.to.exist;
});
it(`the anchor rel is not present`, async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button href="some/path"></wa-icon-button> `);
expect(el.shadowRoot?.querySelector(`a[rel]`)).not.to.exist;
});
describe('and target is present', () => {
['_blank', '_parent', '_self', '_top'].forEach((target: LinkTarget) => {
it(`the anchor target is the provided target: ${target}`, async () => {
const el = await fixture<WaIconButton>(html`
<wa-icon-button href="some/path" target="${target}"></wa-icon-button>
`);
expect(el.shadowRoot?.querySelector(`a[target="${target}"]`)).to.exist;
});
it(`the anchor rel is set to 'noreferrer noopener'`, async () => {
const el = await fixture<WaIconButton>(html`
<wa-icon-button href="some/path" target="${target}"></wa-icon-button>
`);
expect(el.shadowRoot?.querySelector(`a[rel="noreferrer noopener"]`)).to.exist;
});
});
});
describe('and download is present', () => {
it(`the anchor download attribute is the provided download`, async () => {
const fakeDownload = 'some/path';
const el = await fixture<WaIconButton>(html`
<wa-icon-button href="some/path" download="${fakeDownload}"></wa-icon-button>
`);
expect(el.shadowRoot?.querySelector(`a[download="${fakeDownload}"]`)).to.exist;
});
});
});
describe('when label is present', () => {
it('the internal aria-label attribute is set to the provided label when rendering a button', async () => {
const fakeLabel = 'some label';
const el = await fixture<WaIconButton>(html` <wa-icon-button label="${fakeLabel}"></wa-icon-button> `);
expect(el.shadowRoot?.querySelector(`button[aria-label="${fakeLabel}"]`)).to.exist;
});
it('the internal aria-label attribute is set to the provided label when rendering an anchor', async () => {
const fakeLabel = 'some label';
const el = await fixture<WaIconButton>(html`
<wa-icon-button href="some/path" label="${fakeLabel}"></wa-icon-button>
`);
expect(el.shadowRoot?.querySelector(`a[aria-label="${fakeLabel}"]`)).to.exist;
});
});
describe('when disabled is present', () => {
it('the internal button has a disabled attribute when rendering a button', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button disabled></wa-icon-button> `);
expect(el.shadowRoot?.querySelector(`button[disabled]`)).to.exist;
});
it('the internal anchor has an aria-disabled attribute when rendering an anchor', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button href="some/path" disabled></wa-icon-button> `);
expect(el.shadowRoot?.querySelector(`a[aria-disabled="true"]`)).to.exist;
});
});
describe('when using methods', () => {
it('should emit focus and blur when the button is focused and blurred', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button></wa-icon-button> `);
const focusHandler = sinon.spy();
const blurHandler = sinon.spy();
el.addEventListener('focus', focusHandler);
el.addEventListener('blur', blurHandler);
el.focus();
await waitUntil(() => focusHandler.calledOnce);
el.blur();
await waitUntil(() => blurHandler.calledOnce);
expect(focusHandler).to.have.been.calledOnce;
expect(blurHandler).to.have.been.calledOnce;
});
it('should emit a click event when calling click()', async () => {
const el = await fixture<WaIconButton>(html` <wa-icon-button></wa-icon-button> `);
const clickHandler = sinon.spy();
el.addEventListener('click', clickHandler);
el.click();
await waitUntil(() => clickHandler.calledOnce);
expect(clickHandler).to.have.been.calledOnce;
});
});
});
}
});

View File

@@ -0,0 +1,139 @@
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { html, literal } from 'lit/static-html.js';
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-form-associated-element.js';
import '../icon/icon.js';
import styles from './icon-button.css';
/**
* @summary Icons buttons are simple, icon-only buttons that can be used for actions and in toolbars.
* @documentation https://backers.webawesome.com/docs/components/icon-button
* @status stable
* @since 2.0
*
* @dependency wa-icon
*
* @event blur - Emitted when the icon button loses focus.
* @event focus - Emitted when the icon button gains focus.
*
* @cssproperty [--background-color-hover=var(--wa-color-neutral-fill-quiet)] - The color of the button's background on hover.
* @cssproperty [--background-color-active=var(--wa-color-neutral-fill-quiet)] - The color of the button's background on `:active`.
* @cssproperty --text-color-hover - The color of the button's background on hover.
* @cssproperty --text-color-active - The color of the button's background on `:active`.
*
* @csspart base - The component's base wrapper.
*/
@customElement('wa-icon-button')
export default class WaIconButton extends WebAwesomeFormAssociatedElement {
static css = styles;
@query('.icon-button') button: HTMLButtonElement | HTMLLinkElement;
/** The name of the icon to draw. Available names depend on the icon library being used. */
@property({ reflect: true }) name: string | null = null;
/**
* The family of icons to choose from. For Font Awesome, valid options include `classic`, `sharp`, `duotone`, and
* `brands`. Custom icon libraries may or may not use this property.
*/
@property({ reflect: true }) family: string;
/**
* The name of the icon's variant. For Font Awesome, valid options include `thin`, `light`, `regular`, and `solid` for
* the _classic_ and _sharp_ families. Custom icon libraries may or may not use this property.
*/
@property({ reflect: true }) variant: string;
/** The name of a registered custom icon library. */
@property() library?: string;
/**
* An external URL of an SVG file. Be sure you trust the content you are including, as it will be executed as code and
* can result in XSS attacks.
*/
@property() src?: string;
/** When set, the underlying button will be rendered as an `<a>` with this `href` instead of a `<button>`. */
@property() href?: string;
/** Tells the browser where to open the link. Only used when `href` is set. */
@property() target?: '_blank' | '_parent' | '_self' | '_top';
/** Tells the browser to download the linked file as this filename. Only used when `href` is set. */
@property() download?: string;
/**
* A description that gets read by assistive devices. For optimal accessibility, you should always include a label
* that describes what the icon button does.
*/
@property() label = '';
/** Disables the button. */
@property({ type: Boolean }) disabled = false;
private handleClick(event: MouseEvent) {
if (this.disabled) {
event.preventDefault();
event.stopPropagation();
}
}
/** Simulates a click on the icon button. */
click() {
this.button.click();
}
/** Sets focus on the icon button. */
focus(options?: FocusOptions) {
this.button.focus(options);
}
/** Removes focus from the icon button. */
blur() {
this.button.blur();
}
render() {
const isLink = this.href ? true : false;
const tag = isLink ? literal`a` : literal`button`;
/* eslint-disable lit/binding-positions, lit/no-invalid-html */
return html`
<${tag}
part="base"
class=${classMap({
'icon-button': true,
})}
?disabled=${ifDefined(isLink ? undefined : this.disabled)}
type=${ifDefined(isLink ? undefined : 'button')}
href=${ifDefined(isLink ? this.href : undefined)}
target=${ifDefined(isLink ? this.target : undefined)}
download=${ifDefined(isLink ? this.download : undefined)}
rel=${ifDefined(isLink && this.target ? 'noreferrer noopener' : undefined)}
role=${ifDefined(isLink ? undefined : 'button')}
aria-disabled=${this.disabled ? 'true' : 'false'}
aria-label="${this.label}"
tabindex=${this.disabled ? '-1' : '0'}
@click=${this.handleClick}
>
<wa-icon
class="icon"
name=${ifDefined(this.name)}
family=${ifDefined(this.family)}
variant=${ifDefined(this.variant)}
library=${ifDefined(this.library)}
src=${ifDefined(this.src)}
aria-hidden="true"
fixed-width
></wa-icon>
</${tag}>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'wa-icon-button': WaIconButton;
}
}

View File

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

View File

@@ -5,7 +5,6 @@ import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { WaClearEvent } from '../../events/clear.js';
import { HasSlotController } from '../../internal/slot.js';
import { submitOnEnter } from '../../internal/submit-on-enter.js';
import { MirrorValidator } from '../../internal/validators/mirror-validator.js';
import { watch } from '../../internal/watch.js';
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-form-associated-element.js';
@@ -13,6 +12,7 @@ import formControlStyles from '../../styles/component/form-control.css';
import appearanceStyles from '../../styles/utilities/appearance.css';
import sizeStyles from '../../styles/utilities/size.css';
import { LocalizeController } from '../../utilities/localize.js';
import type WaButton from '../button/button.js';
import '../icon/icon.js';
import styles from './input.css';
@@ -245,7 +245,51 @@ export default class WaInput extends WebAwesomeFormAssociatedElement {
}
private handleKeyDown(event: KeyboardEvent) {
submitOnEnter(event, this);
const hasModifier = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
// Pressing enter when focused on an input should submit the form like a native input, but we wait a tick before
// submitting to allow users to cancel the keydown event if they need to
if (event.key === 'Enter' && !hasModifier) {
setTimeout(() => {
//
// When using an Input Method Editor (IME), pressing enter will cause the form to submit unexpectedly. One way
// to check for this is to look at event.isComposing, which will be true when the IME is open.
//
// See https://github.com/shoelace-style/shoelace/pull/988
//
if (!event.defaultPrevented && !event.isComposing) {
const form = this.getForm();
if (!form) {
return;
}
const formElements = [...form.elements];
// If we're the only formElement, we submit like a native input.
if (formElements.length === 1) {
form.requestSubmit(null);
return;
}
const button = formElements.find(
(el: HTMLButtonElement) => el.type === 'submit' && !el.matches(':disabled'),
) as undefined | HTMLButtonElement | WaButton;
// No button found, don't submit.
if (!button) {
return;
}
if (button.tagName.toLowerCase() === 'button') {
form.requestSubmit(button);
} else {
// requestSubmit() wont work with `<wa-button>`
button.click();
}
}
});
}
}
private handlePasswordToggle() {

View File

@@ -0,0 +1,149 @@
:host {
--background-color-hover: var(--wa-color-neutral-fill-normal);
--text-color-hover: var(--wa-color-neutral-on-normal);
--submenu-offset: -0.125rem;
display: block;
color: var(--wa-color-text-normal);
position: relative;
display: flex;
align-items: stretch;
font: inherit;
padding: 0.5em 0.25em;
line-height: var(--wa-line-height-condensed);
transition: fill var(--wa-transition-normal) var(--wa-transition-easing);
user-select: none;
-webkit-user-select: none;
white-space: nowrap;
cursor: pointer;
}
:host([inert]) {
display: none;
}
:host([disabled]) {
outline: none;
opacity: 0.5;
cursor: not-allowed;
}
:host([loading]) {
outline: none;
cursor: wait;
}
:host([loading]) *:not(wa-spinner) {
opacity: 0.5;
}
:host([loading]) wa-spinner {
--indicator-color: currentColor;
--track-width: round(0.0625em, 1px);
position: absolute;
font-size: var(--wa-font-size-smaller);
top: calc(50% - 0.5em);
left: 0.6em;
opacity: 1;
}
.label {
flex: 1 1 auto;
display: inline-block;
text-overflow: ellipsis;
overflow: hidden;
}
.prefix {
flex: 0 0 auto;
display: flex;
align-items: center;
}
.prefix::slotted(*) {
margin-inline-end: 0.5em;
}
.suffix {
flex: 0 0 auto;
display: flex;
align-items: center;
}
.suffix::slotted(*) {
margin-inline-start: 0.5em;
}
/* Safe triangle */
:host(:state(submenu-expanded))::after {
content: '';
position: fixed;
z-index: 899;
top: 0;
right: 0;
bottom: 0;
left: 0;
clip-path: polygon(
var(--safe-triangle-cursor-x, 0) var(--safe-triangle-cursor-y, 0),
var(--safe-triangle-submenu-start-x, 0) var(--safe-triangle-submenu-start-y, 0),
var(--safe-triangle-submenu-end-x, 0) var(--safe-triangle-submenu-end-y, 0)
);
}
:host(:focus-visible) {
outline: none;
}
:host(:hover:not([aria-disabled='true'], :focus-visible)),
:host(:state(submenu-expanded)) {
background-color: var(--background-color-hover);
color: var(--text-color-hover);
}
:host(:focus-visible) {
outline: var(--wa-focus-ring);
outline-offset: calc(-1 * var(--wa-focus-ring-width));
background: var(--background-color-hover);
color: var(--text-color-hover);
opacity: 1;
}
.check,
.chevron {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--wa-font-size-smaller);
width: 2em;
visibility: hidden;
}
:host([checked]) .check,
:host(:state(has-submenu)) .chevron {
visibility: visible;
}
/* Add elevation and z-index to submenus */
wa-popup::part(popup) {
box-shadow: var(--wa-shadow-m);
z-index: 900;
margin-left: var(--submenu-offset);
}
wa-popup:dir(rtl)::part(popup) {
margin-left: calc(-1 * var(--submenu-offset));
}
@media (forced-colors: active) {
:host(:hover:not([aria-disabled='true'])),
:host(:focus-visible) {
outline: dashed 1px SelectedItem;
outline-offset: -1px;
}
}
::slotted(wa-menu) {
max-width: var(--auto-size-available-width) !important;
max-height: var(--auto-size-available-height) !important;
}

View File

@@ -0,0 +1,201 @@
import { aTimeout, expect, waitUntil } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
import { html } from 'lit';
import sinon from 'sinon';
import type { WaSelectEvent } from '../../events/select.js';
import { clickOnElement } from '../../internal/test.js';
import { fixtures } from '../../internal/test/fixture.js';
import type WaMenuItem from './menu-item.js';
describe('<wa-menu-item>', () => {
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('should pass accessibility tests', async () => {
const el = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-item type="checkbox" checked>Checked</wa-menu-item>
<wa-menu-item type="checkbox">Unchecked</wa-menu-item>
</wa-menu>
`);
await expect(el).to.be.accessible();
});
it('should pass accessibility tests when using a submenu', async () => {
const el = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item>
Submenu
<wa-menu slot="submenu">
<wa-menu-item>Submenu Item 1</wa-menu-item>
<wa-menu-item>Submenu Item 2</wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
await expect(el).to.be.accessible();
});
it('should have the correct default properties', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item>Test</wa-menu-item> `);
expect(el.value).to.equal('');
expect(el.disabled).to.be.false;
expect(el.loading).to.equal(false);
expect(el.getAttribute('aria-disabled')).to.equal('false');
});
it('should render the correct aria attributes when disabled', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item disabled>Test</wa-menu-item> `);
expect(el.getAttribute('aria-disabled')).to.equal('true');
});
describe('when loading', () => {
it('should have a spinner present', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item loading>Menu Item Label</wa-menu-item> `);
expect(el.shadowRoot!.querySelector('wa-spinner')).to.exist;
});
});
it('defaultLabel should return a text label', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item>Test</wa-menu-item> `);
expect(el.defaultLabel).to.equal('Test');
expect(el.label).to.equal('Test');
});
it('label attribute should override default label', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item label="Manual label">Text content</wa-menu-item> `);
expect(el.defaultLabel).to.equal('Text content');
expect(el.label).to.equal('Manual label');
});
it('should emit the slotchange event when the label changes', async () => {
const el = await fixture<WaMenuItem>(html` <wa-menu-item>Text</wa-menu-item> `);
const slotChangeHandler = sinon.spy();
el.addEventListener('slotchange', slotChangeHandler);
el.textContent = 'New Text';
await waitUntil(() => slotChangeHandler.calledOnce);
expect(slotChangeHandler).to.have.been.calledOnce;
});
it('should render a hidden menu item when the inert attribute is used', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item inert>Item 1</wa-menu-item>
<wa-menu-item>Item 2</wa-menu-item>
<wa-menu-item>Item 3</wa-menu-item>
</wa-menu>
`);
const item1 = menu.querySelector('wa-menu-item')!;
expect(getComputedStyle(item1).display).to.equal('none');
});
it('should not render a wa-popup if the slot="submenu" attribute is missing, but the slot should exist in the component and be hidden.', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item>
Item 1
<wa-menu>
<wa-menu-item> Nested Item 1 </wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
const menuItem: HTMLElement = menu.querySelector('wa-menu-item')!;
expect(menuItem.shadowRoot!.querySelector('wa-popup')).to.be.null;
const submenuSlot: HTMLElement = menuItem.shadowRoot!.querySelector('slot[name="submenu"]')!;
expect(submenuSlot.hidden).to.be.true;
});
it('should render a wa-popup if the slot="submenu" attribute is present', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item id="test">
Item 1
<wa-menu slot="submenu">
<wa-menu-item> Nested Item 1 </wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
const menuItem = menu.querySelector('wa-menu-item')!;
expect(menuItem.shadowRoot!.querySelector('wa-popup')).to.be.not.null;
const submenuSlot: HTMLElement = menuItem.shadowRoot!.querySelector('slot[name="submenu"]')!;
expect(submenuSlot.hidden).to.be.false;
});
it('should focus on first menuitem of submenu if ArrowRight is pressed on parent menuitem', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item id="item-1">
Submenu
<wa-menu slot="submenu">
<wa-menu-item value="submenu-item-1"> Nested Item 1 </wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
const selectHandler = sinon.spy((event: WaSelectEvent) => {
const item = event.detail.item;
expect(item.value).to.equal('submenu-item-1');
});
menu.addEventListener('wa-select', selectHandler);
const submenu = menu.querySelector<WaMenuItem>('wa-menu-item')!;
// Sometimes Chrome fails if we dont click before triggering focus.
await clickOnElement(submenu);
submenu.focus();
await menu.updateComplete;
await sendKeys({ press: 'ArrowRight' });
await menu.updateComplete;
await sendKeys({ press: 'Enter' });
await menu.updateComplete;
// Once for each menu element.
expect(selectHandler).to.have.been.calledTwice;
});
it('should focus on outer menu if ArrowRight is pressed on nested menuitem', async () => {
const menu = await fixture<WaMenuItem>(html`
<wa-menu>
<wa-menu-item id="outer" value="outer-item-1">
Submenu
<wa-menu slot="submenu">
<wa-menu-item value="inner-item-1"> Nested Item 1 </wa-menu-item>
</wa-menu>
</wa-menu-item>
</wa-menu>
`);
const focusHandler = sinon.spy((event: FocusEvent) => {
const target = event.target as WaMenuItem;
const relatedTarget = event.relatedTarget as WaMenuItem;
expect(target.value).to.equal('outer-item-1');
expect(relatedTarget.value).to.equal('inner-item-1');
});
const outerItem = menu.querySelector<WaMenuItem>('#outer')!;
// Silly fix for CI + Chrome to focus properly.
await clickOnElement(outerItem);
outerItem.focus();
await menu.updateComplete;
await sendKeys({ press: 'ArrowRight' });
outerItem.addEventListener('focus', focusHandler);
await menu.updateComplete;
await sendKeys({ press: 'ArrowLeft' });
await menu.updateComplete;
expect(focusHandler).to.have.been.calledOnce;
});
});
}
});

View File

@@ -0,0 +1,238 @@
import type { PropertyValues } from 'lit';
import { html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import getText from '../../internal/get-text.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import { LocalizeController } from '../../utilities/localize.js';
import '../icon/icon.js';
import '../popup/popup.js';
import '../spinner/spinner.js';
import styles from './menu-item.css';
import { SubmenuController } from './submenu-controller.js';
/**
* @summary Menu items provide options for the user to pick from in a menu.
* @documentation https://backers.webawesome.com/docs/components/menu-item
* @status stable
* @since 2.0
*
* @dependency wa-icon
* @dependency wa-popup
*
* @slot - The menu item's label.
* @slot prefix - Used to prepend an icon or similar element to the menu item.
* @slot suffix - Used to append an icon or similar element to the menu item.
* @slot submenu - Used to denote a nested menu.
* @slot checked-icon - The icon used to indicate that this menu item is checked. Usually a `<wa-icon>`.
* @slot submenu-icon - The icon used to indicate that this menu item has a submenu. Usually a `<wa-icon>`.
*
* @csspart checked-icon - The checked icon, which is only visible when the menu item is checked.
* @csspart prefix - The prefix container.
* @csspart label - The menu item label.
* @csspart suffix - The suffix container.
* @csspart spinner - The spinner that shows when the menu item is in the loading state.
* @csspart spinner__base - The spinner's base part.
* @csspart submenu-icon - The submenu icon, visible only when the menu item has a submenu (not yet implemented).
*
* @cssproperty --background-color-hover - The menu item's background color on hover.
* @cssproperty --text-color-hover - The label color on hover.
* @cssproperty [--submenu-offset=-2px] - The distance submenus shift to overlap the parent menu.
*
* @cssstate has-submenu - Applied when the menu item has a submenu.
* @cssstate submenu-expanded - Applied when the menu item has a submenu and it is expanded.
*/
@customElement('wa-menu-item')
export default class WaMenuItem extends WebAwesomeElement {
static css = styles;
private readonly localize = new LocalizeController(this);
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
@query('.menu-item') menuItem: HTMLElement;
/** The type of menu item to render. To use `checked`, this value must be set to `checkbox`. */
@property() type: 'normal' | 'checkbox' = 'normal';
/** Draws the item in a checked state. */
@property({ type: Boolean, reflect: true }) checked = false;
/** A unique value to store in the menu item. This can be used as a way to identify menu items when selected. */
@property() value = '';
/** Draws the menu item in a loading state. */
@property({ type: Boolean, reflect: true }) loading = false;
/** Draws the menu item in a disabled state, preventing selection. */
@property({ type: Boolean, reflect: true }) disabled = false;
_label: string = '';
/**
* The options plain text label.
* Usually automatically generated, but can be useful to provide manually for cases involving complex content.
*/
@property()
set label(value) {
const oldValue = this._label;
this._label = value || '';
if (this._label !== oldValue) {
this.requestUpdate('label', oldValue);
}
}
get label(): string {
if (this._label) {
return this._label;
}
if (!this.defaultLabel) {
this.updateDefaultLabel();
}
return this.defaultLabel;
}
/** The default label, generated from the element contents. Will be equal to `label` in most cases. */
@state() defaultLabel = '';
/**
* Used for SSR purposes. If true, will render a ">" caret icon for showing that it has a submenu, but will be non-interactive.
*/
@property({ attribute: 'with-submenu', type: Boolean }) withSubmenu = false;
private submenuController: SubmenuController = new SubmenuController(this);
connectedCallback() {
super.connectedCallback();
this.addEventListener('click', this.handleHostClick);
this.addEventListener('mouseover', this.handleMouseOver);
this.updateDefaultLabel();
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('click', this.handleHostClick);
this.removeEventListener('mouseover', this.handleMouseOver);
}
protected firstUpdated(changedProperties: PropertyValues<this>): void {
// Kick it so that it renders the "submenu" properly.
if (this.isSubmenu()) {
this.requestUpdate();
}
super.firstUpdated(changedProperties);
}
private handleDefaultSlotChange() {
let labelChanged = this.updateDefaultLabel();
// When the label changes, emit a slotchange event so parent controls see it
if (labelChanged) {
/** @internal - prevent the CEM from recording this event */
this.dispatchEvent(new Event('slotchange', { bubbles: true, composed: false, cancelable: false }));
}
this.customStates.set('has-submenu', this.isSubmenu());
}
private handleHostClick = (event: MouseEvent) => {
// Prevent the click event from being emitted when the button is disabled or loading
if (this.disabled) {
event.preventDefault();
event.stopImmediatePropagation();
}
};
private handleMouseOver = (event: MouseEvent) => {
this.focus();
event.stopPropagation();
};
updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has('checked')) {
// For proper accessibility, users have to use type="checkbox" to use the checked attribute
if (this.checked && this.type !== 'checkbox') {
this.checked = false;
return;
}
// Only checkbox types can receive the aria-checked attribute
if (this.type === 'checkbox') {
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
} else {
this.removeAttribute('aria-checked');
}
}
if (changedProperties.has('disabled')) {
this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false');
}
if (changedProperties.has('type')) {
if (this.type === 'checkbox') {
this.setAttribute('role', 'menuitemcheckbox');
this.setAttribute('aria-checked', this.checked ? 'true' : 'false');
} else {
this.setAttribute('role', 'menuitem');
this.removeAttribute('aria-checked');
}
}
}
private updateDefaultLabel() {
let oldValue = this.defaultLabel;
this.defaultLabel = getText(this).trim();
let changed = this.defaultLabel !== oldValue;
if (!this._label && changed) {
// Uses default label, and it has changed
this.requestUpdate('label', oldValue);
}
return changed;
}
/** Does this element have a submenu? */
private isSubmenu() {
return this.hasUpdated ? this.querySelector(`:scope > [slot="submenu"]`) !== null : this.withSubmenu;
}
render() {
const isRtl = this.hasUpdated ? this.localize.dir() === 'rtl' : this.dir === 'rtl';
const isSubmenuExpanded = this.submenuController.isExpanded();
this.customStates.set('submenu-expanded', isSubmenuExpanded);
this.internals.ariaHasPopup = this.isSubmenu() + '';
this.internals.ariaExpanded = isSubmenuExpanded + '';
return html`
<slot name="checked-icon" part="checked-icon" class="check">
<wa-icon name="check" library="system" variant="solid" aria-hidden="true"></wa-icon>
</slot>
<slot name="prefix" part="prefix" class="prefix"></slot>
<slot part="label" class="label" @slotchange=${this.handleDefaultSlotChange}></slot>
<slot name="suffix" part="suffix" class="suffix"></slot>
<slot name="submenu-icon" part="submenu-icon" class="chevron">
<wa-icon
name=${isRtl ? 'chevron-left' : 'chevron-right'}
library="system"
variant="solid"
aria-hidden="true"
></wa-icon>
</slot>
${this.submenuController.renderSubmenu()} ${this.loading ? html`<wa-spinner part="spinner"></wa-spinner>` : ''}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'wa-menu-item': WaMenuItem;
}
}

View File

@@ -0,0 +1,285 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';
import { html } from 'lit';
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
import type WaPopup from '../popup/popup.js';
import type WaMenuItem from './menu-item.js';
/** A reactive controller to manage the registration of event listeners for submenus. */
export class SubmenuController implements ReactiveController {
private host: ReactiveControllerHost & WaMenuItem;
private popupRef: Ref<WaPopup> = createRef();
private enableSubmenuTimer = -1;
private isConnected = false;
private isPopupConnected = false;
private skidding = 0;
private readonly submenuOpenDelay = 100;
constructor(host: ReactiveControllerHost & WaMenuItem) {
(this.host = host).addController(this);
}
private hasSubmenu() {
return this.host.querySelector(`:scope > [slot="submenu"]`) !== null;
}
hostConnected() {
if (this.hasSubmenu() && !this.host.disabled) {
this.addListeners();
}
}
hostDisconnected() {
this.removeListeners();
}
hostUpdated() {
if (this.hasSubmenu() && !this.host.disabled) {
this.addListeners();
this.updateSkidding();
} else {
this.removeListeners();
}
}
private addListeners() {
if (!this.isConnected) {
this.host.addEventListener('mousemove', this.handleMouseMove);
this.host.addEventListener('mouseover', this.handleMouseOver);
this.host.addEventListener('keydown', this.handleKeyDown);
this.host.addEventListener('click', this.handleClick);
this.host.addEventListener('focusout', this.handleFocusOut);
this.isConnected = true;
}
// The popup does not seem to get wired when the host is
// connected, so manage its listeners separately.
if (!this.isPopupConnected) {
if (this.popupRef.value) {
this.popupRef.value.addEventListener('mouseover', this.handlePopupMouseover);
this.popupRef.value.addEventListener('wa-reposition', this.handlePopupReposition);
this.isPopupConnected = true;
}
}
}
private removeListeners() {
if (this.isConnected) {
this.host.removeEventListener('mousemove', this.handleMouseMove);
this.host.removeEventListener('mouseover', this.handleMouseOver);
this.host.removeEventListener('keydown', this.handleKeyDown);
this.host.removeEventListener('click', this.handleClick);
this.host.removeEventListener('focusout', this.handleFocusOut);
this.isConnected = false;
}
if (this.isPopupConnected) {
if (this.popupRef.value) {
this.popupRef.value.removeEventListener('mouseover', this.handlePopupMouseover);
this.popupRef.value.removeEventListener('wa-reposition', this.handlePopupReposition);
this.isPopupConnected = false;
}
}
}
// Set the safe triangle cursor position
private handleMouseMove = (event: MouseEvent) => {
this.host.style.setProperty('--safe-triangle-cursor-x', `${event.clientX}px`);
this.host.style.setProperty('--safe-triangle-cursor-y', `${event.clientY}px`);
};
private handleMouseOver = () => {
if (this.hasSubmenu()) {
this.enableSubmenu();
}
};
private handleSubmenuEntry(event: KeyboardEvent) {
// Pass focus to the first menu-item in the submenu.
const submenuSlot: HTMLSlotElement | null = this.host.renderRoot.querySelector("slot[name='submenu']");
// Missing slot
if (!submenuSlot) {
return;
}
// Menus
let menuItems: NodeListOf<Element> | null = null;
for (const elt of submenuSlot.assignedElements()) {
menuItems = elt.querySelectorAll("wa-menu-item, [role^='menuitem']");
if (menuItems.length !== 0) {
break;
}
}
if (!menuItems || menuItems.length === 0) {
return;
}
menuItems[0].setAttribute('tabindex', '0');
for (let i = 1; i !== menuItems.length; ++i) {
menuItems[i].setAttribute('tabindex', '-1');
}
// Open the submenu (if not open), and set focus to first menuitem.
if (this.popupRef.value) {
event.preventDefault();
event.stopPropagation();
if (this.popupRef.value.active) {
if (menuItems[0] instanceof HTMLElement) {
menuItems[0].focus();
}
} else {
this.enableSubmenu(false);
this.host.updateComplete.then(() => {
if (menuItems[0] instanceof HTMLElement) {
menuItems[0].focus();
}
});
this.host.requestUpdate();
}
}
}
// Focus on the first menu-item of a submenu.
private handleKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'Escape':
case 'Tab':
this.disableSubmenu();
break;
case 'ArrowLeft':
// Either focus is currently on the host element or a child
if (event.target !== this.host) {
event.preventDefault();
event.stopPropagation();
this.host.focus();
this.disableSubmenu();
}
break;
case 'ArrowRight':
case 'Enter':
case ' ':
this.handleSubmenuEntry(event);
break;
default:
break;
}
};
private handleClick = (event: MouseEvent) => {
// Clicking on the item which heads the menu does nothing, otherwise hide submenu and propagate
if (event.target === this.host) {
event.preventDefault();
event.stopPropagation();
} else if (
event.target instanceof Element &&
(event.target.tagName === 'wa-menu-item' || event.target.role?.startsWith('menuitem'))
) {
this.disableSubmenu();
}
};
// Close this submenu on focus outside of the parent or any descendants.
private handleFocusOut = (event: FocusEvent) => {
if (event.relatedTarget && event.relatedTarget instanceof Element && this.host.contains(event.relatedTarget)) {
return;
}
this.disableSubmenu();
};
// Prevent the parent menu-item from getting focus on mouse movement on the submenu
private handlePopupMouseover = (event: MouseEvent) => {
event.stopPropagation();
};
// Set the safe triangle values for the submenu when the position changes
private handlePopupReposition = () => {
const submenuSlot: HTMLSlotElement | null = this.host.renderRoot.querySelector("slot[name='submenu']");
const menu = submenuSlot?.assignedElements({ flatten: true }).filter(el => el.localName === 'wa-menu')[0];
const isRtl = getComputedStyle(this.host).direction === 'rtl';
if (!menu) {
return;
}
const { left, top, width, height } = menu.getBoundingClientRect();
this.host.style.setProperty('--safe-triangle-submenu-start-x', `${isRtl ? left + width : left}px`);
this.host.style.setProperty('--safe-triangle-submenu-start-y', `${top}px`);
this.host.style.setProperty('--safe-triangle-submenu-end-x', `${isRtl ? left + width : left}px`);
this.host.style.setProperty('--safe-triangle-submenu-end-y', `${top + height}px`);
};
private setSubmenuState(state: boolean) {
if (this.popupRef.value) {
if (this.popupRef.value.active !== state) {
this.popupRef.value.active = state;
this.host.requestUpdate();
}
}
}
// Shows the submenu. Supports disabling the opening delay, e.g. for keyboard events that want to set the focus to the
// newly opened menu.
private enableSubmenu(delay = true) {
if (delay) {
window.clearTimeout(this.enableSubmenuTimer);
this.enableSubmenuTimer = window.setTimeout(() => {
this.setSubmenuState(true);
}, this.submenuOpenDelay);
} else {
this.setSubmenuState(true);
}
}
private disableSubmenu() {
window.clearTimeout(this.enableSubmenuTimer);
this.setSubmenuState(false);
}
// Calculate the space the top of a menu takes-up, for aligning the popup menu-item with the activating element.
private updateSkidding(): void {
// .computedStyleMap() not always available.
if (!this.host.parentElement?.computedStyleMap) {
return;
}
const styleMap: StylePropertyMapReadOnly = this.host.parentElement.computedStyleMap();
const attrs: string[] = ['padding-top', 'border-top-width', 'margin-top'];
const skidding = attrs.reduce((accumulator, attr) => {
const styleValue: CSSStyleValue = styleMap.get(attr) ?? new CSSUnitValue(0, 'px');
const unitValue = styleValue instanceof CSSUnitValue ? styleValue : new CSSUnitValue(0, 'px');
const pxValue = unitValue.to('px');
return accumulator - pxValue.value;
}, 0);
this.skidding = skidding;
}
isExpanded(): boolean {
return this.popupRef.value ? this.popupRef.value.active : false;
}
renderSubmenu() {
// Always render the slot, but conditionally render the outer <wa-popup>
if (!this.host.hasUpdated) {
return html` <slot name="submenu" hidden></slot> `;
}
const isRtl = getComputedStyle(this.host).direction === 'rtl';
return html`
<wa-popup
${ref(this.popupRef)}
placement=${isRtl ? 'left-start' : 'right-start'}
.anchor="${this.host}"
flip
flip-fallback-strategy="best-fit"
skidding="${this.skidding}"
auto-size="vertical"
auto-size-padding="10"
>
<slot name="submenu"></slot>
</wa-popup>
`;
}
}

View File

@@ -0,0 +1,9 @@
:host {
display: block;
color: var(--wa-color-text-quiet);
font-size: var(--wa-font-size-smaller);
font-weight: var(--wa-font-weight-semibold);
padding: 0.5em 2.25em;
-webkit-user-select: none;
user-select: none;
}

View File

@@ -0,0 +1,15 @@
import { expect } from '@open-wc/testing';
import { html } from 'lit';
import { fixtures } from '../../internal/test/fixture.js';
import type WaMenuLabel from './menu-label.js';
describe('<wa-menu-label>', () => {
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('passes accessibility test', async () => {
const el = await fixture<WaMenuLabel>(html` <wa-menu-label>Test</wa-menu-label> `);
await expect(el).to.be.accessible();
});
});
}
});

View File

@@ -0,0 +1,27 @@
import { html } from 'lit';
import { customElement } from 'lit/decorators.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import styles from './menu-label.css';
/**
* @summary Menu labels are used to describe a group of menu items.
* @documentation https://backers.webawesome.com/docs/components/menu-label
* @status stable
* @since 2.0
*
* @slot - The menu label's content.
*/
@customElement('wa-menu-label')
export default class WaMenuLabel extends WebAwesomeElement {
static css = styles;
render() {
return html`<slot></slot>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'wa-menu-label': WaMenuLabel;
}
}

View File

@@ -0,0 +1,15 @@
:host {
display: block;
position: relative;
text-align: start;
background-color: var(--wa-color-surface-raised);
border: var(--wa-border-style) var(--wa-border-width-s) var(--wa-color-surface-border);
border-radius: var(--wa-border-radius-m);
padding: 0.5em 0;
overflow: auto;
overscroll-behavior: none;
}
::slotted(wa-divider) {
--spacing: 0.5em;
}

View File

@@ -0,0 +1,127 @@
import { expect } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
import { html } from 'lit';
import sinon from 'sinon';
import type { WaSelectEvent } from '../../events/select.js';
import { clickOnElement } from '../../internal/test.js';
import { fixtures } from '../../internal/test/fixture.js';
import type WaMenu from './menu.js';
describe('<wa-menu>', () => {
for (const fixture of fixtures) {
describe(`with "${fixture.type}" rendering`, () => {
it('emits wa-select with the correct event detail when clicking an item', async () => {
const menu = await fixture<WaMenu>(html`
<wa-menu>
<wa-menu-item value="item-1">Item 1</wa-menu-item>
<wa-menu-item value="item-2">Item 2</wa-menu-item>
<wa-menu-item value="item-3">Item 3</wa-menu-item>
<wa-menu-item value="item-4">Item 4</wa-menu-item>
</wa-menu>
`);
const item2 = menu.querySelectorAll('wa-menu-item')[1];
const selectHandler = sinon.spy((event: WaSelectEvent) => {
const item = event.detail.item;
if (item !== item2) {
expect.fail('Incorrect event detail emitted with wa-select');
}
});
menu.addEventListener('wa-select', selectHandler);
await clickOnElement(item2);
expect(selectHandler).to.have.been.calledOnce;
});
it('can be selected via keyboard', async () => {
const menu = await fixture<WaMenu>(html`
<wa-menu>
<wa-menu-item value="item-1">Item 1</wa-menu-item>
<wa-menu-item value="item-2">Item 2</wa-menu-item>
<wa-menu-item value="item-3">Item 3</wa-menu-item>
<wa-menu-item value="item-4">Item 4</wa-menu-item>
</wa-menu>
`);
const [item1, item2] = menu.querySelectorAll('wa-menu-item');
const selectHandler = sinon.spy((event: WaSelectEvent) => {
const item = event.detail.item;
if (item !== item2) {
expect.fail('Incorrect item selected');
}
});
menu.addEventListener('wa-select', selectHandler);
item1.focus();
await item1.updateComplete;
await sendKeys({ press: 'ArrowDown' });
await sendKeys({ press: 'Enter' });
expect(selectHandler).to.have.been.calledOnce;
});
it('does not select disabled items when clicking', async () => {
const menu = await fixture<WaMenu>(html`
<wa-menu>
<wa-menu-item value="item-1">Item 1</wa-menu-item>
<wa-menu-item value="item-2" disabled>Item 2</wa-menu-item>
<wa-menu-item value="item-3">Item 3</wa-menu-item>
<wa-menu-item value="item-4">Item 4</wa-menu-item>
</wa-menu>
`);
const item2 = menu.querySelectorAll('wa-menu-item')[1];
const selectHandler = sinon.spy();
menu.addEventListener('wa-select', selectHandler);
await clickOnElement(item2);
expect(selectHandler).to.not.have.been.calledOnce;
});
it('does not select disabled items when pressing enter', async () => {
const menu = await fixture<WaMenu>(html`
<wa-menu>
<wa-menu-item value="item-1">Item 1</wa-menu-item>
<wa-menu-item value="item-2" disabled>Item 2</wa-menu-item>
<wa-menu-item value="item-3">Item 3</wa-menu-item>
<wa-menu-item value="item-4">Item 4</wa-menu-item>
</wa-menu>
`);
const [item1, item2] = menu.querySelectorAll('wa-menu-item');
const selectHandler = sinon.spy();
menu.addEventListener('wa-select', selectHandler);
item1.focus();
await item1.updateComplete;
await sendKeys({ press: 'ArrowDown' });
expect(document.activeElement).to.equal(item2);
await sendKeys({ press: 'Enter' });
await item2.updateComplete;
expect(selectHandler).to.not.have.been.called;
});
// @see https://github.com/shoelace-style/shoelace/issues/1596
it('Should fire "wa-select" when clicking an element within a menu-item', async () => {
// eslint-disable-next-line
const selectHandler = sinon.spy(() => {});
const menu: WaMenu = await fixture(html`
<wa-menu>
<wa-menu-item>
<span>Menu item</span>
</wa-menu-item>
</wa-menu>
`);
menu.addEventListener('wa-select', selectHandler);
const span = menu.querySelector('span')!;
await clickOnElement(span);
expect(selectHandler).to.have.been.calledOnce;
});
});
}
});

View File

@@ -0,0 +1,172 @@
import { html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { WaSelectEvent } from '../../events/select.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import sizeStyles from '../../styles/utilities/size.css';
import '../menu-item/menu-item.js';
import type WaMenuItem from '../menu-item/menu-item.js';
import styles from './menu.css';
export interface MenuSelectEventDetail {
item: WaMenuItem;
}
/**
* @summary Menus provide a list of options for the user to choose from.
* @documentation https://backers.webawesome.com/docs/components/menu
* @status stable
* @since 2.0
*
* @dependency wa-menu-item
*
* @slot - The menu's content, including menu items, menu labels, and dividers.
*
* @event {{ item: WaMenuItem }} wa-select - Emitted when a menu item is selected.
*/
@customElement('wa-menu')
export default class WaMenu extends WebAwesomeElement {
static css = [sizeStyles, styles];
/** The component's size. */
@property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
@query('slot') defaultSlot: HTMLSlotElement;
connectedCallback() {
super.connectedCallback();
this.setAttribute('role', 'menu');
}
private handleClick(event: MouseEvent) {
const menuItemTypes = ['menuitem', 'menuitemcheckbox'];
const target = event.composedPath().find((el: Element) => menuItemTypes.includes(el?.getAttribute?.('role') || ''));
if (!target) return;
// This isn't true. But we use it for TypeScript checks below.
const item = target as WaMenuItem;
if (item.type === 'checkbox') {
item.checked = !item.checked;
}
this.dispatchEvent(new WaSelectEvent({ item }));
}
private handleKeyDown(event: KeyboardEvent) {
// Make a selection when pressing enter or space
if (event.key === 'Enter' || event.key === ' ') {
const item = this.getCurrentItem();
event.preventDefault();
event.stopPropagation();
// Simulate a click to support @click handlers on menu items that also work with the keyboard
item?.click();
}
// Move the selection when pressing down or up
else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
const items = this.getAllItems();
const activeItem = this.getCurrentItem();
let index = activeItem ? items.indexOf(activeItem) : 0;
if (items.length > 0) {
event.preventDefault();
event.stopPropagation();
if (event.key === 'ArrowDown') {
index++;
} else if (event.key === 'ArrowUp') {
index--;
} else if (event.key === 'Home') {
index = 0;
} else if (event.key === 'End') {
index = items.length - 1;
}
if (index < 0) {
index = items.length - 1;
}
if (index > items.length - 1) {
index = 0;
}
this.setCurrentItem(items[index]);
items[index].focus();
}
}
}
private handleMouseDown(event: MouseEvent) {
const target = event.target as HTMLElement;
if (this.isMenuItem(target)) {
this.setCurrentItem(target as WaMenuItem);
}
}
private handleSlotChange() {
const items = this.getAllItems();
// Reset the roving tab index when the slotted items change
if (items.length > 0) {
this.setCurrentItem(items[0]);
}
}
private isMenuItem(item: HTMLElement) {
return (
item.tagName.toLowerCase() === 'wa-menu-item' ||
['menuitem', 'menuitemcheckbox', 'menuitemradio'].includes(item.getAttribute('role') ?? '')
);
}
/** @internal Gets all slotted menu items, ignoring dividers, headers, and other elements. */
getAllItems() {
return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => {
if (el.inert || !this.isMenuItem(el)) {
return false;
}
return true;
}) as WaMenuItem[];
}
/**
* @internal Gets the current menu item, which is the menu item that has `tabindex="0"` within the roving tab index.
* The menu item may or may not have focus, but for keyboard interaction purposes it's considered the "active" item.
*/
getCurrentItem() {
return this.getAllItems().find(i => i.getAttribute('tabindex') === '0');
}
/**
* @internal Sets the current menu item to the specified element. This sets `tabindex="0"` on the target element and
* `tabindex="-1"` to all other items. This method must be called prior to setting focus on a menu item.
*/
setCurrentItem(item: WaMenuItem) {
const items = this.getAllItems();
// Update tab indexes
items.forEach(i => {
i.setAttribute('tabindex', i === item ? '0' : '-1');
});
}
render() {
return html`
<slot
@slotchange=${this.handleSlotChange}
@click=${this.handleClick}
@keydown=${this.handleKeyDown}
@mousedown=${this.handleMouseDown}
></slot>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'wa-menu': WaMenu;
}
}

View File

@@ -23,11 +23,9 @@
outline: none;
}
@media (hover: hover) {
:host(:not([disabled], :state(current)):is(:state(hover), :hover)) {
background-color: var(--background-color-hover);
color: var(--text-color-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,10 +141,8 @@
border-start-end-radius: 0;
}
@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']: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,10 +198,8 @@ label:has(select),
outline: none;
}
@media (hover: hover) {
&:hover {
color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover));
}
&:hover {
color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover));
}
&:active {

View File

@@ -343,10 +343,10 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
private handleDocumentKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement;
const isClearButton = target.closest('[part~="clear-button"]') !== null;
const isButton = target.closest('wa-button') !== null;
const isIconButton = target.closest('wa-icon-button') !== null;
// Ignore presses when the target is a button (e.g. the remove button in `<wa-tag>`)
if (isClearButton || isButton) {
// Ignore presses when the target is an icon button (e.g. the remove button in `<wa-tag>`)
if (isClearButton || isIconButton) {
return;
}
@@ -484,10 +484,10 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
private handleComboboxMouseDown(event: MouseEvent) {
const path = event.composedPath();
const isButton = path.some(el => el instanceof Element && el.tagName.toLowerCase() === 'wa-button');
const isIconButton = path.some(el => el instanceof Element && el.tagName.toLowerCase() === 'wa-icon-button');
// Ignore disabled controls and clicks on tags (remove buttons)
if (this.disabled || isButton) {
if (this.disabled || isIconButton) {
return;
}

View File

@@ -1,227 +1,214 @@
:host {
--track-size: 0.5em;
--thumb-width: 1.4em;
--thumb-height: 1.4em;
--marker-width: 0.1875em;
--marker-height: 0.1875em;
}
--thumb-color: var(--wa-form-control-activated-color);
--thumb-gap: calc(var(--thumb-size) * 0.125);
--thumb-shadow: initial;
--thumb-size: calc(1em * var(--wa-form-control-value-line-height));
:host([orientation='vertical']) {
width: auto;
}
--track-color-active: var(--wa-color-neutral-fill-normal);
--track-color-inactive: var(--wa-color-neutral-fill-normal);
--track-active-offset: 0%;
--track-height: calc(var(--thumb-size) * 0.25);
--tooltip-offset: calc(var(--wa-tooltip-arrow-size) * 1.375);
#label:has(~ .vertical) {
display: block;
order: 2;
max-width: none;
text-align: center;
}
#description:has(~ .vertical) {
order: 3;
text-align: center;
}
/* Add extra space between slider and label, when present */
#label:has(*:not(:empty)) ~ #slider {
&.horizontal {
margin-block-start: 0.5em;
}
&.vertical {
margin-block-end: 0.5em;
}
}
#slider {
&:focus {
outline: none;
}
&:focus-visible:not(.disabled) #thumb,
&:focus-visible:not(.disabled) #thumb-min,
&:focus-visible:not(.disabled) #thumb-max {
outline: var(--wa-focus-ring);
/* intentionally no offset due to border */
}
}
#track {
position: relative;
border-radius: 9999px;
background: var(--wa-color-neutral-fill-normal);
isolation: isolate;
}
/* Orientation */
.horizontal #track {
height: var(--track-size);
}
.vertical #track {
order: 1;
width: var(--track-size);
height: 200px;
}
/* Disabled */
.disabled #track {
cursor: not-allowed;
opacity: 0.5;
}
/* Indicator */
#indicator {
position: absolute;
border-radius: inherit;
background-color: var(--wa-form-control-activated-color);
&:dir(ltr) {
right: calc(100% - max(var(--start), var(--end)));
left: min(var(--start), var(--end));
}
&:dir(rtl) {
right: min(var(--start), var(--end));
left: calc(100% - max(var(--start), var(--end)));
}
}
.horizontal #indicator {
top: 0;
height: 100%;
}
.vertical #indicator {
top: calc(100% - var(--end));
bottom: var(--start);
left: 0;
width: 100%;
}
/* Thumbs */
#thumb,
#thumb-min,
#thumb-max {
z-index: 3;
position: absolute;
width: var(--thumb-width);
height: var(--thumb-height);
border: solid 0.125em var(--wa-color-surface-default);
border-radius: 50%;
background-color: var(--wa-form-control-activated-color);
cursor: pointer;
}
.disabled #thumb,
.disabled #thumb-min,
.disabled #thumb-max {
cursor: inherit;
}
.horizontal #thumb,
.horizontal #thumb-min,
.horizontal #thumb-max {
top: calc(50% - var(--thumb-height) / 2);
&:dir(ltr) {
right: auto;
left: calc(var(--position) - var(--thumb-width) / 2);
}
&:dir(rtl) {
right: calc(var(--position) - var(--thumb-width) / 2);
left: auto;
}
}
.vertical #thumb,
.vertical #thumb-min,
.vertical #thumb-max {
bottom: calc(var(--position) - var(--thumb-height) / 2);
left: calc(50% - var(--thumb-width) / 2);
}
/* Range-specific thumb styles */
:host([range]) {
#thumb-min:focus-visible,
#thumb-max:focus-visible {
z-index: 4; /* Ensure focused thumb appears on top */
outline: var(--wa-focus-ring);
/* intentionally no offset due to border */
}
}
/* Markers */
#markers {
pointer-events: none;
}
.marker {
z-index: 2;
position: absolute;
width: var(--marker-width);
height: var(--marker-height);
border-radius: 50%;
background-color: var(--wa-color-surface-default);
}
.marker:first-of-type,
.marker:last-of-type {
display: none;
}
.horizontal .marker {
top: calc(50% - var(--marker-height) / 2);
left: calc(var(--position) - var(--marker-width) / 2);
}
.vertical .marker {
top: calc(var(--position) - var(--marker-height) / 2);
left: calc(50% - var(--marker-width) / 2);
}
/* Marker labels */
#references {
position: relative;
slot {
display: flex;
justify-content: space-between;
height: 100%;
}
::slotted(*) {
color: var(--wa-color-text-quiet);
font-size: 0.875em;
line-height: 1;
}
}
.horizontal {
#references {
margin-block-start: 0.5em;
}
}
.vertical {
display: flex;
margin-inline: auto;
flex-direction: column;
position: relative;
min-height: max(var(--thumb-size), var(--track-height));
}
#track {
order: 1;
input[type='range'] {
--percent: 0%;
-webkit-appearance: none;
border-radius: calc(var(--track-height) / 2);
width: 100%;
height: var(--track-height);
font-size: inherit;
line-height: var(--wa-form-control-height);
vertical-align: middle;
margin: 0;
--dir: right;
background-image: linear-gradient(
to var(--dir),
var(--track-color-inactive) min(var(--percent), var(--track-active-offset)),
var(--track-color-active) min(var(--percent), var(--track-active-offset)),
var(--track-color-active) max(var(--percent), var(--track-active-offset)),
var(--track-color-inactive) max(var(--percent), var(--track-active-offset))
);
&:dir(rtl) {
--dir: left;
}
#references {
order: 2;
width: min-content;
margin-inline-start: 0.75em;
&::-webkit-slider-runnable-track {
width: 100%;
height: var(--track-height);
border-radius: 3px;
border: none;
}
slot {
flex-direction: column;
&::-webkit-slider-thumb {
width: var(--thumb-size);
height: var(--thumb-size);
border-radius: 50%;
background-color: var(--thumb-color);
box-shadow:
var(--thumb-shadow, 0 0 transparent),
0 0 0 var(--thumb-gap) var(--wa-color-surface-default);
-webkit-appearance: none;
margin-top: calc(var(--thumb-size) / -2 + var(--track-height) / 2);
transition: var(--wa-transition-fast);
transition-property: width, height;
}
&:enabled {
&:focus-visible::-webkit-slider-thumb {
outline: var(--wa-focus-ring);
outline-offset: var(--wa-focus-ring-offset);
}
&::-webkit-slider-thumb {
cursor: pointer;
}
&::-webkit-slider-thumb:active {
cursor: grabbing;
}
}
&::-moz-focus-outer {
border: 0;
}
&::-moz-range-progress {
background-color: var(--track-color-active);
border-radius: 3px;
height: var(--track-height);
}
&::-moz-range-track {
width: 100%;
height: var(--track-height);
background-color: var(--track-color-inactive);
border-radius: 3px;
border: none;
}
&::-moz-range-thumb {
height: var(--thumb-size);
width: var(--thumb-size);
border-radius: 50%;
background-color: var(--thumb-color);
box-shadow:
var(--thumb-shadow, 0 0 transparent),
0 0 0 var(--thumb-gap) var(--wa-color-surface-default);
transition-property: background-color, border-color, box-shadow, color;
transition-duration: var(--wa-transition-normal);
transition-timing-function: var(--wa-transition-easing);
}
&:enabled {
&:focus-visible::-moz-range-thumb {
outline: var(--wa-focus-ring);
outline-offset: var(--wa-focus-ring-offset);
}
&::-moz-range-thumb {
cursor: pointer;
}
&::-moz-range-thumb:active {
cursor: grabbing;
}
}
}
.vertical #references slot {
flex-direction: column;
/* States */
/* nesting these styles yields broken results in Safari */
input[type='range']:focus {
outline: none;
}
:host :has(:disabled) input[type='range'] {
opacity: 0.5;
cursor: not-allowed;
&::-moz-range-thumb,
&::-webkit-slider-thumb {
cursor: not-allowed;
}
}
/* Tooltip output */
.tooltip {
position: absolute;
z-index: 1000;
inset-inline-start: 0;
inset-block-end: calc(50% + (var(--thumb-size) / 2) + var(--tooltip-offset));
border-radius: var(--wa-tooltip-border-radius);
background-color: var(--wa-tooltip-background-color);
font-family: inherit;
font-size: var(--wa-tooltip-font-size);
line-height: var(--wa-tooltip-line-height);
color: var(--wa-tooltip-content-color);
opacity: 0;
padding: 0.25em 0.5em;
transition: var(--wa-transition-normal) opacity;
pointer-events: none;
&::after {
content: '';
position: absolute;
width: 0;
height: 0;
inset-inline-start: 50%;
inset-block-start: 100%;
translate: calc(-1 * var(--wa-tooltip-arrow-size));
border-inline: var(--wa-tooltip-arrow-size) solid transparent;
border-block-start: var(--border-block);
}
&:dir(rtl)::after {
translate: var(--wa-tooltip-arrow-size);
}
&.visible {
opacity: 1;
}
--inset-block: calc(50% + (var(--thumb-size) / 2) + var(--tooltip-offset));
--border-block: var(--wa-tooltip-arrow-size) solid var(--wa-tooltip-background-color);
@media (forced-colors: active) {
border: solid 1px transparent;
&::after {
display: none;
}
}
}
/* RTL tooltip positioning */
:host(:dir(rtl)) .tooltip {
inset-inline-start: auto;
inset-inline-end: 0;
}
/* Tooltip on bottom */
:host([tooltip='bottom']) .tooltip {
inset-block-end: auto;
inset-block-start: calc(50% + (var(--thumb-size) / 2) + var(--tooltip-offset));
&::after {
border-block-end: var(--border-block);
inset-block-start: auto;
inset-block-end: 100%;
}
}
/* Bottom tooltip RTL fix */
:host([tooltip='bottom']:dir(rtl)) .tooltip {
inset-inline-start: auto;
inset-inline-end: 0;
}

View File

@@ -21,8 +21,9 @@ describe('<wa-slider>', () => {
it('default properties', async () => {
const el = await fixture<WaSlider>(html` <wa-slider></wa-slider> `);
expect(el.name).to.equal(null);
expect(el.name).to.equal('');
expect(el.value).to.equal(0);
expect(el.title).to.equal('');
expect(el.label).to.equal('');
expect(el.hint).to.equal('');
expect(el.disabled).to.be.false;
@@ -30,16 +31,22 @@ describe('<wa-slider>', () => {
expect(el.min).to.equal(0);
expect(el.max).to.equal(100);
expect(el.step).to.equal(1);
expect(el.tooltipPlacement).to.equal('top');
expect(el.tooltip).to.equal('top');
expect(el.defaultValue).to.equal(0);
});
it('should have title if title attribute is set', async () => {
const el = await fixture<WaSlider>(html` <wa-slider title="Test"></wa-slider> `);
const input = el.shadowRoot!.querySelector('input')!;
expect(input.title).to.equal('Test');
});
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<WaSlider>(html` <wa-slider disabled></wa-slider> `);
const input = el.shadowRoot!.querySelector<HTMLElement>("[role='slider']")!;
const input = el.shadowRoot!.querySelector<HTMLInputElement>('.control')!;
expect(el.matches(':disabled')).to.be.true;
expect(input.getAttribute('aria-disabled')).to.equal('true');
expect(input.disabled).to.be.true;
});
describe('when the value changes', () => {

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,7 @@ interface ClientRectangles {
const waitForScrollButtonsToBeRendered = async (tabGroup: WaTabGroup): Promise<void> => {
await waitUntil(() => {
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-button');
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-icon-button');
return scrollButtons?.length === 2;
});
};
@@ -234,7 +234,7 @@ describe('<wa-tab-group>', () => {
await waitForScrollButtonsToBeRendered(tabGroup);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-button');
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-icon-button');
expect(scrollButtons, 'Both scroll buttons should be shown').to.have.length(2);
tabGroup.disconnectedCallback();
@@ -248,7 +248,7 @@ describe('<wa-tab-group>', () => {
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-button');
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-icon-button');
expect(scrollButtons).to.have.length(0);
});
@@ -259,7 +259,7 @@ describe('<wa-tab-group>', () => {
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-button');
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-icon-button');
expect(scrollButtons).to.have.length(0);
});
@@ -270,7 +270,7 @@ describe('<wa-tab-group>', () => {
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-button');
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-icon-button');
expect(scrollButtons).to.have.length(0);
});
@@ -281,7 +281,7 @@ describe('<wa-tab-group>', () => {
await aTimeout(0);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-button');
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-icon-button');
expect(scrollButtons).to.have.length(0);
});
@@ -293,7 +293,7 @@ describe('<wa-tab-group>', () => {
);
await waitForScrollButtonsToBeRendered(tabGroup);
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-button');
const scrollButtons = tabGroup.shadowRoot?.querySelectorAll('wa-icon-button');
expect(scrollButtons).to.have.length(2);
const firstTab = tabGroup.querySelector('[panel="tab-0"]');
@@ -303,7 +303,7 @@ describe('<wa-tab-group>', () => {
expect(isElementVisibleFromOverflow(tabGroup, firstTab!)).to.be.true;
expect(isElementVisibleFromOverflow(tabGroup, lastTab!)).to.be.false;
const scrollToRightButton = tabGroup.shadowRoot?.querySelector('wa-button[part*="scroll-button-end"]');
const scrollToRightButton = tabGroup.shadowRoot?.querySelector('wa-icon-button[part*="scroll-button-end"]');
expect(scrollToRightButton).not.to.be.null;
await clickOnElement(scrollToRightButton!);

View File

@@ -7,7 +7,7 @@ import { scrollIntoView } from '../../internal/scroll.js';
import { watch } from '../../internal/watch.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import { LocalizeController } from '../../utilities/localize.js';
import '../button/button.js';
import '../icon-button/icon-button.js';
import '../tab-panel/tab-panel.js';
import type WaTabPanel from '../tab-panel/tab-panel.js';
import '../tab/tab.js';
@@ -20,7 +20,7 @@ import styles from './tab-group.css';
* @status stable
* @since 2.0
*
* @dependency wa-button
* @dependency wa-icon-button
* @dependency wa-tab
* @dependency wa-tab-panel
*
@@ -35,7 +35,7 @@ import styles from './tab-group.css';
* @csspart nav - The tab group's navigation container where tabs are slotted in.
* @csspart tabs - The container that wraps the tabs.
* @csspart body - The tab group's body where tab panels are slotted in.
* @csspart scroll-button - The previous/next scroll buttons that show when tabs are scrollable, a `<wa-button>`.
* @csspart scroll-button - The previous/next scroll buttons that show when tabs are scrollable, an `<wa-icon-button>`.
* @csspart scroll-button-start - The starting scroll button.
* @csspart scroll-button-end - The ending scroll button.
* @csspart scroll-button__base - The scroll button's exported `base` part.
@@ -400,20 +400,16 @@ export default class WaTabGroup extends WebAwesomeElement {
<div class="nav-container" part="nav">
${this.hasScrollControls
? html`
<wa-button
<wa-icon-button
part="scroll-button scroll-button-start"
exportparts="base:scroll-button__base"
class="scroll-button scroll-button-start"
appearance="plain"
name=${isRtl ? 'chevron-right' : 'chevron-left'}
library="system"
variant="solid"
label=${this.localize.term('scrollToStart')}
@click=${this.handleScrollToStart}
>
<wa-icon
name=${isRtl ? 'chevron-right' : 'chevron-left'}
library="system"
variant="solid"
label=${this.localize.term('scrollToStart')}
></wa-icon>
</wa-button>
></wa-icon-button>
`
: ''}
@@ -426,20 +422,16 @@ export default class WaTabGroup extends WebAwesomeElement {
${this.hasScrollControls
? html`
<wa-button
<wa-icon-button
part="scroll-button scroll-button-end"
class="scroll-button scroll-button-end"
exportparts="base:scroll-button__base"
appearance="plain"
name=${isRtl ? 'chevron-left' : 'chevron-right'}
library="system"
variant="solid"
label=${this.localize.term('scrollToEnd')}
@click=${this.handleScrollToEnd}
>
<wa-icon
name=${isRtl ? 'chevron-left' : 'chevron-right'}
library="system"
variant="solid"
label=${this.localize.term('scrollToEnd')}
></wa-icon>
</wa-button>
></wa-icon-button>
`
: ''}
</div>

View File

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

View File

@@ -18,6 +18,8 @@ let id = 0;
* @cssproperty --active-tab-color - The color of the active tab's label.
*
* @csspart base - The component's base wrapper.
* @csspart close-button - The close button, an `<wa-icon-button>`.
* @csspart base - The close button's exported `base` part.
*/
@customElement('wa-tab')
export default class WaTab extends WebAwesomeElement {

View File

@@ -24,23 +24,19 @@
[part='remove-button'] {
color: inherit;
line-height: 1;
padding: 0;
}
[part='remove-button']::part(base) {
padding: 0;
height: 1em;
width: 1em;
}
@media (hover: hover) {
:host(:hover) > [part='remove-button']::part(base) {
color: color-mix(in oklab, currentColor, var(--wa-color-mix-hover));
}
:host(:hover) > wa-icon-button {
color: color-mix(in oklab, var(--text-color), var(--wa-color-mix-hover));
}
:host(:active) > [part='remove-button']::part(base) {
color: color-mix(in oklab, currentColor, var(--wa-color-mix-active));
:host(:active) > wa-icon-button {
color: color-mix(in oklab, var(--text-color), var(--wa-color-mix-active));
}
/*

View File

@@ -31,7 +31,7 @@ describe('<wa-tag>', () => {
it('should set removable by attribute', async () => {
const el = await fixture<WaTag>(html` <wa-tag with-remove>Test</wa-tag> `);
const removeButton = el.shadowRoot!.querySelector('wa-button');
const removeButton = el.shadowRoot!.querySelector('wa-icon-button');
expect(el.withRemove).to.be.true;
expect(removeButton).to.exist;
@@ -40,7 +40,7 @@ describe('<wa-tag>', () => {
describe('removable', () => {
it('should emit remove event when remove button clicked', async () => {
const el = await fixture<WaTag>(html` <wa-tag with-remove>Test</wa-tag> `);
const removeButton = el.shadowRoot!.querySelector('wa-button');
const removeButton = el.shadowRoot!.querySelector('wa-icon-button');
const spy = sinon.spy();
el.addEventListener('wa-remove', spy, { once: true });

View File

@@ -6,7 +6,7 @@ import appearanceStyles from '../../styles/utilities/appearance.css';
import sizeStyles from '../../styles/utilities/size.css';
import variantStyles from '../../styles/utilities/variants.css';
import { LocalizeController } from '../../utilities/localize.js';
import '../button/button.js';
import '../icon-button/icon-button.js';
import styles from './tag.css';
/**
@@ -15,7 +15,7 @@ import styles from './tag.css';
* @status stable
* @since 2.0
*
* @dependency wa-button
* @dependency wa-icon-button
*
* @slot - The tag's content.
*
@@ -23,7 +23,7 @@ import styles from './tag.css';
*
* @csspart base - The component's base wrapper.
* @csspart content - The tag's content.
* @csspart remove-button - The tag's remove button, a `<wa-button>`.
* @csspart remove-button - The tag's remove button, an `<wa-icon-button>`.
* @csspart remove-button__base - The remove button's exported `base` part.
*/
@customElement('wa-tag')
@@ -58,16 +58,17 @@ export default class WaTag extends WebAwesomeElement {
${this.withRemove
? html`
<wa-button
<wa-icon-button
part="remove-button"
exportparts="base:remove-button__base"
name="xmark"
library="system"
variant="solid"
label=${this.localize.term('remove')}
class="remove"
appearance="plain"
@click=${this.handleRemoveClick}
tabindex="-1"
>
<wa-icon name="xmark" library="system" variant="solid" label=${this.localize.term('remove')}></wa-icon>
</wa-button>
></wa-icon-button>
`
: ''}
`;

View File

@@ -0,0 +1,145 @@
: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 {
display: none;
vertical-align: -0.1em;
font-size: 85%;
}
wa-icon-button {
&::part(base) {
padding: 0;
}
}
.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

@@ -0,0 +1,446 @@
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 '../icon-button/icon-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-icon-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-icon-button
name="arrow-rotate-left"
variant="regular"
label="Revert resizing"
@click=${() => this.iframe.removeAttribute('style')}
part="undo button"
>-</wa-icon-button
>`
: ''}
${dimensions}
<span class="zoom">
<wa-icon-button
name="square-minus"
variant="regular"
label="Zoom out"
@click=${() => this.zoomOut()}
part="zoom-out button"
>-</wa-icon-button
>
<span class="zoom-level">
<wa-icon name="magnifying-glass-plus"></wa-icon>
${Math.round(this.computedZoom * 100)}%
</span>
<wa-icon-button
name="square-plus"
variant="regular"
label="Zoom in"
@click=${() => this.zoomIn()}
part="zoom-in button"
>+</wa-icon-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

@@ -1,82 +0,0 @@
: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;
}
}

Some files were not shown because too many files have changed in this diff Show More