Compare commits

...

51 Commits

Author SHA1 Message Date
Cory LaViska
15c77082a4 remove outdated section 2025-03-28 10:49:56 -04:00
Cory LaViska
5161936cb0 add option as a dep of select 2025-03-28 10:48:11 -04:00
Cory LaViska
f6e754c1b9 Merge branch 'next' into bug-fixes 2025-03-28 10:43:05 -04:00
Cory LaViska
8ad1a58914 revert example 2025-03-28 10:38:29 -04:00
Cory LaViska
5b1ef2717c fix radio labels (ALPHA-211) 2025-03-28 09:59:18 -04:00
Cory LaViska
30302fe632 update display labels when changed; fixes #702 2025-03-28 09:47:31 -04:00
Cory LaViska
22c0fd2301 fix whitespace 2025-03-28 09:41:34 -04:00
Cory LaViska
d9b3df2bea remove wa-cloak after components load 2025-03-28 09:24:46 -04:00
Cory LaViska
2c047c2843 add FA kit code for codepen 🤞🏻 2025-03-28 09:24:29 -04:00
Konnor Rogers
21aa85acc0 fix search for webawesome app (#845)
* fix search for webawesome app

* prettier
2025-03-27 16:51:41 -04:00
Lea Verou
404c15b303 Fix race condition, closes #843 2025-03-27 16:14:24 -04:00
Cory LaViska
9d3c0142b8 update test 2025-03-27 15:12:21 -04:00
Cory LaViska
acf02a7a03 stop running SSR tests locally 2025-03-27 15:07:38 -04:00
Lea Verou
8a26afc334 Fix for theme icons + easier to generate palette icons (#841)
* Make sure components that only appear within page icons are still detected

* Palette icons

* Update theme-icons.css

* Reduce whitespace between swatches

---------

Co-authored-by: lindsaym-fa <dev@lindsaym.design>
2025-03-27 14:25:52 -04:00
Cory LaViska
50034527e7 add comment 2025-03-27 14:13:31 -04:00
Cory LaViska
96e8ce8bf6 fix native checkbox indeterminate icon; closes #386 2025-03-27 14:08:24 -04:00
Cory LaViska
b8dd847b4b fix progress animation in Safari; closes #356 2025-03-27 13:20:55 -04:00
Cory LaViska
95fd134104 fix dropdown alignment in button group; closes #374 2025-03-27 12:52:02 -04:00
Cory LaViska
c6ccb3280d Merge branch 'next' into bug-fixes 2025-03-27 12:16:22 -04:00
Cory LaViska
513a1e35a9 Dialog fixes (#790)
* revert structure and styles to fix WA-A #123

* fix WA-A #201

* update changelog

* fix search dialog position so it doesn't jump around

* remove close watcher; fix dialog/drawer backdrop animations
2025-03-27 12:14:35 -04:00
Cory LaViska
aeb1c57be0 revert 2025-03-27 11:51:36 -04:00
Lea Verou
09f668fc99 Workaround for dark mode 2025-03-26 18:31:08 -04:00
Lea Verou
d451ba98e5 Fix web fonts in theme icons
Instead of raw DSD, use a component that pulls in a child template and then goes over the CSS and extracts font-related rules into the document, just once per rule.
This also fixes theme icons in Vue.
2025-03-26 18:31:08 -04:00
lindsaym-fa
fd287edd56 Change balance of color swatches 2025-03-26 18:31:08 -04:00
Lea Verou
8424b49646 Theme icons, take 1 2025-03-26 18:31:08 -04:00
Cory LaViska
486cf3d408 revert 2025-03-26 16:14:25 -04:00
Cory LaViska
4f81a432df Revert "improve details styles; fixes #685"
This reverts commit 8151872d22.
2025-03-26 16:09:39 -04:00
Cory LaViska
bef6ee8503 update examples 2025-03-26 15:52:49 -04:00
Cory LaViska
8151872d22 improve details styles; fixes #685 2025-03-26 15:22:29 -04:00
Cory LaViska
d78b984bd0 fix focus ring in Safari; fixes #745 2025-03-26 15:01:03 -04:00
Cory LaViska
2615fc8ab9 fixes #840 2025-03-26 14:38:33 -04:00
Cory LaViska
0d8d8d4055 remove redundant styles 2025-03-26 14:38:24 -04:00
Cory LaViska
be3cfc3420 fix radio button pill styles; fixes #759 2025-03-26 13:46:30 -04:00
Lea Verou
fa24c0f70e Update changelog.md 2025-03-26 13:08:44 -04:00
Cory LaViska
f1149d22a0 add default icon spacing in tab; fixes #779 2025-03-26 13:05:57 -04:00
Cory LaViska
1bba87c66d Improve search lists (#837)
* add debounce to search so it feels more natural

* improve search grid styles
2025-03-26 16:07:09 +00:00
Cory LaViska
0db9ca12e3 Remove unused SSR module and remove first load fade (#835)
* disable SSR module in 11ty

* remove first load fade
2025-03-26 14:45:29 +00:00
Lea Verou
041555fe99 border-radius: 0 on plain details 2025-03-26 10:04:25 -04:00
Lea Verou
b41dbd2de7 Fix: Specify default card background 2025-03-25 16:53:16 -04:00
Lea Verou
7c6f31e0c7 [Card docs] Use style utilities instead of custom CSS 2025-03-25 16:31:40 -04:00
Lea Verou
9e84274a93 [Card] Round all corners of the image for appearance=plain 2025-03-25 16:31:40 -04:00
Lea Verou
2b3803f91e [Card] Support appearance, closes #609 2025-03-25 16:31:40 -04:00
Lea Verou
faed8da3cd Fix broken link 2025-03-25 14:14:53 -04:00
Lea Verou
17cf902f53 Add appearance to details, closes #569
Except `accent` as that's a) far less useful and b) trickier due to the icon color
2025-03-25 14:14:53 -04:00
Lea Verou
8214ff6b2d Several fixes around overviews, outlines etc (#825)
* Fix outline for headings that have links

Previously produced blank items because it assumed any link in a heading is an anchor

* Filter unlisted items from overviews

Previously they were filtered only when the card was rendered, so their heading was still shown

* [Overview] Add id to group headings

* Hide headings from empty groups

Should never happen but you never know

* [Overview] Ensure "Other" is always last even when no sorting
2025-03-25 11:39:04 -04:00
Cory LaViska
c9979e15f8 adds a hard coded delay to drastically reduce theme picker jank (#829) 2025-03-24 20:49:08 +00:00
Cory LaViska
fcfe2bde7d Add FOUCE utilities (#686)
* add fouce utilities

* add comment

* Update docs/docs/installation.md

Co-authored-by: Lea Verou <lea@verou.me>

* commit PR suggestion

* rename wa-reduce-fouce to wa-cloak

* remove class as requested

* add cloak class

* wait a cycle

* move turbo to same file

* reduce fade

* disable SSR and add Turbo FOUCE helper

* disable SSR

* fix test suite

* workflow dispatch

* update fouce util

* no need to remove cloak class

* simplify fouce util

* add allDefined util

* update changelog

---------

Co-authored-by: Lea Verou <lea@verou.me>
Co-authored-by: konnorrogers <konnor5456@gmail.com>
2025-03-24 20:33:24 +00:00
Lea Verou
59dcaaff83 Content hierarchy bugfixes & improvements (#821)
- Sidebar, overview listings, breadcrumbs now based on actual parent-child relationships, rather than increasingly outdated heuristics
- parent properties are now generated automatically from the URL structure, and need only be specified to override that default
- Ability to group by page hierarchy in overview pages, where pages that have >= 2 children become categories

Smaller improvements:
- More flexible syntax for specifying the params of overview pages
- [Overviews] Hide group heading if only one group is present
- parentItem and parentUrl properties that can be used on any page
- Alias a collection as the children of a page (useful for "virtual" parents like Layout)
- Do not error if a page card icon is missing
2025-03-21 16:30:06 -04:00
Cory LaViska
5bad30ec30 fix remove event and return null when empty (#819)
* fix remove event and return null when empty

* use closest
2025-03-21 13:01:49 -04:00
Lea Verou
87c1762146 Scrub :host-context() from everywhere 2025-03-21 12:55:25 -04:00
Konnor Rogers
899edd1d5e Konnorrogers/add a guard for non server deploys (#818)
* add a guard for non-server builds

* add a guard for non-server builds

* add a guard for non-server builds

* prettier
2025-03-20 16:37:22 -04:00
86 changed files with 1467 additions and 648 deletions

View File

@@ -1,11 +1,12 @@
# # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
# This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: SSR Tests
on:
push:
branches: [next]
# push:
# branches: [next]
workflow_dispatch:
jobs:
ssr_test:

View File

@@ -7,9 +7,10 @@ import { highlightCodePlugin } from './_utils/highlight-code.js';
import { markdown } from './_utils/markdown.js';
import { removeDataAlphaElements } from './_utils/remove-data-alpha-elements.js';
// import { formatCodePlugin } from './_utils/format-code.js';
import litPlugin from '@lit-labs/eleventy-plugin-lit';
// import litPlugin from '@lit-labs/eleventy-plugin-lit';
import { readFile } from 'fs/promises';
import componentList from './_data/componentList.js';
import nunjucks from 'nunjucks';
// import componentList from './_data/componentList.js';
import * as filters from './_utils/filters.js';
import { outlinePlugin } from './_utils/outline.js';
import { replaceTextPlugin } from './_utils/replace-text.js';
@@ -28,7 +29,6 @@ const globalData = {
package: packageData,
isAlpha,
layout: 'page.njk',
server: {
head: '',
loginOrAvatar: '',
@@ -40,6 +40,11 @@ const passThroughExtensions = ['js', 'css', 'png', 'svg', 'jpg', 'mp4'];
const passThrough = [...passThroughExtensions.map(ext => 'docs/**/*.' + ext)];
export default function (eleventyConfig) {
/**
* This is the guard we use for now to make sure our final built files dont need a 2nd pass by the server. This keeps us able to still deploy the bare HTML files on Vercel until the app is ready.
*/
const serverBuild = process.env.WEBAWESOME_SERVER === 'true';
// NOTE - alpha setting removes certain pages
if (isAlpha) {
eleventyConfig.ignores.add('**/experimental/**');
@@ -68,9 +73,35 @@ export default function (eleventyConfig) {
return `https://early.webawesome.com/webawesome@${packageData.version}/dist/` + (location || '').replace(/^\//, '');
});
// Turns `{% server_variable "foo" %} into `{{ server.foo | safe }}`
// Turns `{% server "foo" %} into `{{ server.foo | safe }}` when the WEBAWESOME_SERVER variable is set to "true"
eleventyConfig.addShortcode('server', function (property) {
return `{{ server.${property} | safe }}`;
if (serverBuild) {
return `{{ server.${property} | safe }}`;
}
return '';
});
eleventyConfig.addTransform('second-nunjucks-transform', function NunjucksTransform(content) {
// For a server build, we expect a server to run the second transform.
if (serverBuild) {
return content;
}
// Only run the transform on files nunjucks would transform.
if (!this.page.inputPath.match(/.(md|html|njk)$/)) {
return content;
}
/** This largely mimics what an app would do and just stubs out what we don't care about. */
return nunjucks.renderString(content, {
// Stub the server EJS shortcodes.
server: {
head: '',
loginOrAvatar: '',
flashes: '',
},
});
});
// Paired shortcodes - {% shortCode %}content{% endShortCode %}
@@ -132,30 +163,6 @@ export default function (eleventyConfig) {
]),
);
// SSR plugin
if (!isDev) {
//
// Problematic components in SSR land:
// - animation (breaks on navigation + ssr with Turbo)
// - mutation-observer (why SSR this?)
// - resize-observer (why SSR this?)
// - tooltip (why SSR this?)
//
const omittedModules = [];
const componentModules = componentList
.filter(component => !omittedModules.includes(component.tagName.split(/wa-/)[1]))
.map(component => {
const name = component.tagName.split(/wa-/)[1];
const componentDirectory = process.env.UNBUNDLED_DIST_DIRECTORY || path.join('.', 'dist');
return path.join(componentDirectory, 'components', name, `${name}.js`);
});
eleventyConfig.addPlugin(litPlugin, {
mode: 'worker',
componentModules,
});
}
// Build the search index
eleventyConfig.addPlugin(
searchPlugin({
@@ -182,6 +189,31 @@ export default function (eleventyConfig) {
eleventyConfig.addPassthroughCopy(glob);
}
// // SSR plugin
// // Make sure this is the last thing, we don't want to run the risk of accidentally transforming shadow roots with the nunjucks 2nd transform.
// if (!isDev) {
// //
// // Problematic components in SSR land:
// // - animation (breaks on navigation + ssr with Turbo)
// // - mutation-observer (why SSR this?)
// // - resize-observer (why SSR this?)
// // - tooltip (why SSR this?)
// //
// const omittedModules = [];
// const componentModules = componentList
// .filter(component => !omittedModules.includes(component.tagName.split(/wa-/)[1]))
// .map(component => {
// const name = component.tagName.split(/wa-/)[1];
// const componentDirectory = process.env.UNBUNDLED_DIST_DIRECTORY || path.join('.', 'dist');
// return path.join(componentDirectory, 'components', name, `${name}.js`);
// });
//
// eleventyConfig.addPlugin(litPlugin, {
// mode: 'worker',
// componentModules,
// });
// }
return {
markdownTemplateEngine: 'njk',
dir: {

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en" data-fa-kit-code="b10bfbde90" data-cdn-url="{% cdnUrl %}">
<html lang="en" data-fa-kit-code="b10bfbde90" data-cdn-url="{% cdnUrl %}" class="wa-cloak">
<head>
{% include 'head.njk' %}
<meta name="theme-color" content="#f36944">

View File

@@ -1,8 +1,11 @@
{% set breadcrumbs = page.url | breadcrumbs %}
{% if breadcrumbs.length > 0 %}
{% set ancestors = page.url | ancestors %}
{% if ancestors.length > 0 %}
<wa-breadcrumb id="docs-breadcrumbs">
{% for crumb in breadcrumbs %}
<wa-breadcrumb-item href="{{ crumb.url }}">{{ crumb.title }}</wa-breadcrumb-item>
{% for ancestor in ancestors %}
{% if ancestor.page.url != "/" %}
<wa-breadcrumb-item href="{{ ancestor.page.url }}">{{ ancestor.data.title }}</wa-breadcrumb-item>
{% endif %}
{% endfor %}
<wa-breadcrumb-item>{# Current page #}</wa-breadcrumb-item>
</wa-breadcrumb>

View File

@@ -1,12 +1,18 @@
{# Cards for pages listed by category #}
<section id="grid" class="index-grid">
{% for category, pages in allPages | groupByTags(categories) -%}
<h2 class="index-category">{{ category | getCategoryTitle(categories) }}</h2>
{%- for page in pages -%}
{%- if not page.data.parent or listChildren -%}
{% include "page-card.njk" %}
{%- endif -%}
{%- endfor -%}
{% set groupedPages = allPages | groupPages(categories, page) %}
{% for category, pages in groupedPages -%}
{% if groupedPages.meta.groupCount > 1 and pages.length > 0 %}
<h2 class="index-category" id="{{ category | slugify }}">
{% if pages.meta.url %}<a href="{{ pages.meta.url }}">{{ pages.meta.title }}</a>
{% else %}
{{ pages.meta.title }}
{% endif %}
</h2>
{% endif %}
{%- for page in pages -%}
{% include "page-card.njk" %}
{%- endfor -%}
{%- endfor -%}
</section>

View File

@@ -23,10 +23,12 @@
<script src="/assets/scripts/hydration-errors.js"></script>
<link rel="stylesheet" href="/assets/styles/hydration-errors.css">
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.10/+esm"></script>
{# Internal components #}
<script type="module" src="/assets/components/scoped.js"></script>
{# Web Awesome #}
<script type="module" src="/dist/webawesome.ssr-loader.js"></script>
<script type="module" src="/dist/webawesome.loader.js"></script>
<script type="module" src="/assets/scripts/theme-picker.js"></script>
{# Preset Theme #}
@@ -48,6 +50,5 @@
<link id="color-stylesheet" rel="stylesheet" href="/dist/styles/utilities.css" />
<link rel="stylesheet" href="/dist/styles/forms.css" />
{# Used by Web Awesome App to inject other assets into the head. #}
{% server "head" %}

View File

@@ -2,7 +2,7 @@
<a href="{{ page.url }}"{{ page.data.keywords | attr('data-keywords') }}>
<wa-card with-header>
<div slot="header">
{% include "svgs/" + (page.data.icon or "thumbnail-placeholder") + ".njk" %}
{% include "svgs/" + (page.data.icon or "thumbnail-placeholder") + ".njk" ignore missing %}
</div>
<span class="page-name">{{ page.data.title }}</span>
{% if pageSubtitle -%}

View File

@@ -1,9 +1,12 @@
{# Some collections (like "patterns") will not have any items in the alpha build for example. So this checks to make sure the collection exists. #}
{% if collections[tag] -%}
{% set groupUrl %}/docs/{{ tag }}/{% endset %}
{% set groupItem = groupUrl | getCollectionItemFromUrl %}
{% set children = groupItem.data.children if groupItem.data.children.length > 0 else (collections[tag] | sort) %}
<wa-details {{ ((tag in (tags or [])) or (groupUrl in page.url)) | attr('open') }}>
<h2 slot="summary">
{% if groupUrl | getCollectionItemFromUrl %}
{% if groupItem %}
<a href="{{ groupUrl }}" title="Overview">{{ title or (tag | capitalize) }}
<wa-icon name="grid-2"></wa-icon>
</a>
@@ -12,10 +15,8 @@
{% endif %}
</h2>
<ul>
{% for page in collections[tag] | sort %}
{% if not page.data.parent -%}
{% for page in children %}
{% include 'sidebar-link.njk' %}
{%- endif %}
{% endfor %}
</ul>
</wa-details>

View File

@@ -1,4 +1,4 @@
{% if not (isAlpha and page.data.noAlpha) and page.fileSlug != tag and not page.data.unlisted -%}
{% if page | show -%}
<li>
<a href="{{ page.url }}">{{ page.data.title }}</a>
{% if page.data.status == 'experimental' %}<wa-icon name="flask"></wa-icon>{% endif %}

View File

@@ -1,31 +1,20 @@
{% set paletteId = palette.fileSlug or page.fileSlug %}
{% set suffixes = ['-80', '', '-20'] %}
{% set width = 20 %}
{% set height = 12 %}
{% set height_core = 20 %}
{% set gap_x = 4 %}
{% set gap_y = 4 %}
{% set total_width = (width + gap_x) * hues|length %}
{% set total_height = (height + gap_y) * suffixes|length + (height_core - height) %}
<svg viewBox="0 0 {{ total_width }} {{ total_height }}" fill="none" xmlns="http://www.w3.org/2000/svg" class="wa-palette-{{ paletteId }} palette-icon">
<style>
@import url('/dist/styles/color/{{ paletteId }}.css') layer(palette.{{ paletteId }});
.palette-icon {
height: 8ch;
}
</style>
<wa-scoped class="palette-icon-host">
<template>
<link rel="stylesheet" href="/dist/styles/color/{{ paletteId }}.css">
<link rel="stylesheet" href="/assets/styles/theme-icons.css">
{% for hue in hues -%}
{% set hueIndex = loop.index0 %}
{% set y = 0 %}
{% for suffix in suffixes -%}
{% set swatch_height = height if suffix else height_core %}
<rect x="{{ hueIndex * (width + gap_x) }}" y="{{ y }}"
width="{{ width }}" height="{{ swatch_height }}"
fill="var(--wa-color-{{ hue }}{{ suffix }})" rx="2" />
{% set y = y + swatch_height + gap_y %}
{%- endfor %}
{% endfor %}
</svg>
<div class="palette-icon" style="--hues: {{ hues|length }}; --suffixes: {{ suffixes|length }}">
{% for hue in hues -%}
{% set hueIndex = loop.index %}
{% for suffix in suffixes -%}
<div class="swatch"
data-hue="{{ hue }}" data-suffix="{{ suffix }}"
style="--color: var(--wa-color-{{ hue }}{{ suffix }}); grid-column: {{ hueIndex }}; grid-row: {{ loop.index }}">&nbsp;</div>
{%- endfor %}
{%- endfor %}
</div>
</template>
</wa-scoped>

View File

@@ -1,13 +1,13 @@
{% set themeId = theme.fileSlug %}
<div>
<template shadowrootmode="open">
<wa-scoped class="theme-icon-host theme-color-icon-host">
<template>
<link rel="stylesheet" href="/dist/styles/utilities.css">
<link rel="stylesheet" href="/dist/styles/themes/{{ page.fileSlug or 'default' }}.css">
<link rel="stylesheet" href="/dist/styles/themes/{{ themeId }}/color.css">
<link rel="stylesheet" href="/assets/styles/theme-icons.css">
<div class="theme-color-icon wa-theme-{{ themeId }}">
<div class="theme-icon theme-color-icon wa-theme-{{ themeId }}">
<div class="wa-brand wa-accent">A</div>
<div class="wa-brand wa-outlined">A</div>
<div class="wa-brand wa-filled">A</div>
@@ -21,4 +21,4 @@
{# <div class="wa-warning wa-outlined wa-filled"><wa-icon slot="icon" name="triangle-exclamation" variant="regular"></wa-icon></div> #}
</div>
</template>
</div>
</wa-scoped>

View File

@@ -1,16 +1,16 @@
{% set themeId = theme.fileSlug %}
{% set themeId = theme.fileSlug or page.fileSlug %}
<div>
<template shadowrootmode="open">
<wa-scoped class="theme-icon-host theme-typography-icon-host">
<template>
<link rel="stylesheet" href="/dist/styles/native/content.css">
<link rel="stylesheet" href="/dist/styles/native/blockquote.css">
<link rel="stylesheet" href="/dist/styles/themes/{{ page.fileSlug or 'default' }}.css">
<link rel="stylesheet" href="/dist/styles/themes/{{ themeId }}/typography.css">
<link rel="stylesheet" href="/assets/styles/theme-icons.css">
<div class="theme-typography-icon wa-theme-{{ themeId }}" data-no-outline data-no-anchor role="presentation">
<div class="theme-icon theme-typography-icon wa-theme-{{ themeId }}" data-no-outline data-no-anchor role="presentation">
<h3>Title</h3>
<p>Body text</p>
</div>
</template>
</div>
</wa-scoped>

View File

@@ -0,0 +1,29 @@
{% set themeId = theme.fileSlug or page.fileSlug %}
<wa-scoped class="theme-icon-host theme-overall-icon-host">
<template>
<link rel="stylesheet" href="/dist/styles/utilities.css">
<link rel="stylesheet" href="/dist/styles/native/content.css">
<link rel="stylesheet" href="/dist/styles/themes/{{ themeId }}.css">
<link rel="stylesheet" href="/assets/styles/theme-icons.css">
<div class="theme-icon theme-overall-icon" role="presentation" data-no-anchor data-no-outline>
<div class="row row-1">
<h2>Aa</h2>
<div class="swatches">
<div class="wa-brand"></div>
<div class="wa-success"></div>
<div class="wa-warning"></div>
<div class="wa-danger"></div>
<div class="wa-neutral"></div>
</div>
</div>
<div class="row row-2">
<wa-input value="Input" size="small"></wa-input>
<wa-button size="small" variant="brand">Go</wa-button>
</div>
</div>
</template>
</wa-scoped>

View File

@@ -1,6 +1,5 @@
---
layout: page-outline
tags: ["overview"]
---
{% set forTag = forTag or (page.url | split('/') | last) %}
{% if description %}
@@ -13,8 +12,10 @@ tags: ["overview"]
</wa-input>
</div>
{% set allPages = collections[forTag] %}
{% set allPages = allPages or collections[forTag] %}
{% if allPages and allPages.length > 0 %}
{% include "grouped-pages.njk" %}
{% endif %}
<link href="/assets/styles/filter.css" rel="stylesheet">
<script type="module" src="/assets/scripts/filter.js"></script>

View File

@@ -68,7 +68,7 @@ wa_data.palettes = {
<wa-option label="{{ palette.data.title }}" value="{{ palette.fileSlug if not currentPalette }}" {{ (palette.fileSlug if currentPalette) | attr('data-id') }}>
<wa-card with-header>
<div slot="header">
{% include "svgs/" + (palette.data.icon or "thumbnail-placeholder") + ".njk" %}
{% include "svgs/" + (palette.data.icon or "thumbnail-placeholder") + ".njk" ignore missing %}
</div>
<span class="page-name">
{{ palette.data.title }}

View File

@@ -29,6 +29,9 @@ function getCollection(name) {
}
export function getCollectionItemFromUrl(url, collection) {
if (!url) {
return null;
}
collection ??= getCollection.call(this, 'all') || [];
return collection.find(item => item.url === url);
}
@@ -42,35 +45,33 @@ export function split(text, separator) {
return (text + '').split(separator).filter(Boolean);
}
export function breadcrumbs(url, { withCurrent = false } = {}) {
const parts = split(url, '/');
const ret = [];
export function ancestors(url, { withCurrent = false, withRoot = false } = {}) {
let ret = [];
let currentUrl = url;
let currentItem = getCollectionItemFromUrl.call(this, url);
while (parts.length) {
let partialUrl = '/' + parts.join('/') + '/';
let item = getCollectionItemFromUrl.call(this, partialUrl);
if (item && (partialUrl !== url || withCurrent)) {
let title = item.data.title;
if (title) {
ret.unshift({ url: partialUrl, title });
}
}
parts.pop();
if (item?.data.parent) {
let parentURL = item.data.parent;
if (!item.data.parent.startsWith('/')) {
// Parent is in the same directory
parts.push(item.data.parent);
parentURL = '/' + parts.join('/') + '/';
}
let parentBreadcrumbs = breadcrumbs.call(this, parentURL, { withCurrent: true });
return [...parentBreadcrumbs, ...ret];
if (!currentItem) {
// Might have eleventyExcludeFromCollections, jump to parent
let parentUrl = this.ctx.parentUrl;
if (parentUrl) {
url = parentUrl;
}
}
for (let item; (item = getCollectionItemFromUrl.call(this, url)); url = item.data.parentUrl) {
ret.unshift(item);
}
if (!withRoot && ret[0]?.page.url === '/') {
// Remove root
ret.shift();
}
if (!withCurrent && ret.at(-1)?.page.url === currentUrl) {
// Remove current page
ret.pop();
}
return ret;
}
@@ -177,72 +178,196 @@ export function sort(arr, by = { 'data.order': 1, 'data.title': '' }) {
});
}
export function show(page) {
return !(page.data.noAlpha && page.data.isAlpha) && !page.data.unlisted;
}
/**
* Group an 11ty collection (or any array of objects with a `data.tags` property) by certain tags.
* @param {object[]} collection
* @param { Object<string, string> | (string | Object<string, string>)[]} [tags] The tags to group by. If not provided/empty, defaults to grouping by all tags.
* @returns { Object.<string, object[]> } An object with keys for each tag, and an array of items for each tag.
* @param { Object<string, string> | string[]} [options] Options object or array of tags to group by.
* @param {string[] | true} [options.tags] Tags to group by. If true, groups by all tags.
* If not provided/empty, defaults to grouping by page hierarchy, with any pages with more than 1 children becoming groups.
* @param {string[]} [options.groups] The groups to use if only a subset or a specific order is desired. Defaults to `options.tags`.
* @param {string[]} [options.titles] Any title overrides for groups.
* @param {string | false} [options.other="Other"] The title to use for the "Other" group. If `false`, the "Other" group is removed..
* @returns { Object.<string, object[]> } An object of group ids to arrays of page objects.
*/
export function groupByTags(collection, tags) {
export function groupPages(collection, options = {}, page) {
if (!collection) {
console.error(`Empty collection passed to groupByTags() to group by ${JSON.stringify(tags)}`);
}
if (!tags) {
// Default to grouping by union of all tags
tags = Array.from(new Set(collection.flatMap(item => item.data.tags)));
} else if (Array.isArray(tags)) {
// May contain objects of one-off tag -> label mappings
tags = tags.map(tag => (typeof tag === 'object' ? Object.keys(tag)[0] : tag));
} else if (typeof tags === 'object') {
// tags is an object of tags to labels, so we just want the keys
tags = Object.keys(tags);
console.error(`Empty collection passed to groupPages() to group by ${JSON.stringify(options)}`);
}
let ret = Object.fromEntries(tags.map(tag => [tag, []]));
ret.other = [];
if (Array.isArray(options)) {
options = { tags: options };
}
let { tags, groups, titles = {}, other = 'Other', filter = show } = options;
if (groups === undefined && Array.isArray(tags)) {
groups = tags;
}
let grouping;
if (tags) {
grouping = {
isGroup: item => undefined,
getCandidateGroups: item => item.data.tags,
getGroupMeta: group => ({}),
};
} else {
grouping = {
isGroup: item => (item.data.children.length >= 2 ? item.page.url : undefined),
getCandidateGroups: item => {
let parentUrl = item.data.parentUrl;
if (page?.url === parentUrl) {
return [];
}
return [parentUrl];
},
getGroupMeta: group => {
let item = byUrl[group] || getCollectionItemFromUrl.call(this, group);
return {
title: item?.data.title,
url: group,
item,
};
},
sortGroups: groups => sort(groups.map(url => byUrl[url]).filter(Boolean)).map(item => item.page.url),
};
}
let byUrl = {};
let byParentUrl = {};
if (filter) {
collection = collection.filter(filter);
}
for (let item of collection) {
let categorized = false;
let url = item.page.url;
let parentUrl = item.data.parentUrl;
for (let tag of tags) {
if (item.data.tags.includes(tag)) {
ret[tag].push(item);
categorized = true;
}
}
byUrl[url] = item;
if (!categorized) {
ret.other.push(item);
if (parentUrl) {
byParentUrl[parentUrl] ??= [];
byParentUrl[parentUrl].push(item);
}
}
// Remove empty categories
for (let category in ret) {
if (ret[category].length === 0) {
delete ret[category];
let urlToGroups = {};
for (let item of collection) {
let url = item.page.url;
let parentUrl = item.data.parentUrl;
if (grouping.isGroup(item)) {
continue;
}
let parentItem = byUrl[parentUrl];
if (parentItem && !grouping.isGroup(parentItem)) {
// Their parent is also here and is not a group
continue;
}
let candidateGroups = grouping.getCandidateGroups(item);
if (groups) {
candidateGroups = candidateGroups.filter(group => groups.includes(group));
}
urlToGroups[url] ??= [];
for (let group of candidateGroups) {
urlToGroups[url].push(group);
}
}
let ret = {};
for (let url in urlToGroups) {
let groups = urlToGroups[url];
let item = byUrl[url];
if (groups.length === 0) {
// Not filtered out but also not categorized
groups = ['other'];
}
for (let group of groups) {
ret[group] ??= [];
ret[group].push(item);
if (!ret[group].meta) {
if (group === 'other') {
ret[group].meta = { title: other };
} else {
ret[group].meta = grouping.getGroupMeta(group);
ret[group].meta.title = titles[group] ?? ret[group].meta.title ?? capitalize(group);
}
}
}
}
if (other === false) {
delete ret.other;
}
// Sort
let sortedGroups = groups ?? grouping.sortGroups?.(Object.keys(ret));
if (sortedGroups) {
ret = sortObject(ret, sortedGroups);
} else {
// At least make sure other is last
if (ret.other) {
let otherGroup = ret.other;
delete ret.other;
ret.other = otherGroup;
}
}
Object.defineProperty(ret, 'meta', {
value: {
groupCount: Object.keys(ret).length,
},
enumerable: false,
});
return ret;
}
/**
* Sort an object by its keys
* @param {*} obj
* @param {function | string[]} order
*/
function sortObject(obj, order) {
let ret = {};
let sortedKeys = Array.isArray(order) ? order : Object.keys(obj).sort(order);
for (let key of sortedKeys) {
if (key in obj) {
ret[key] = obj[key];
}
}
// Add any keys that weren't in the order
for (let key in obj) {
if (!(key in ret)) {
ret[key] = obj[key];
}
}
return ret;
}
export function getCategoryTitle(category, categories) {
let title;
if (Array.isArray(categories)) {
// Find relevant entry
// [{id: "Title"}, id2, ...]
title = categories.find(entry => typeof entry === 'object' && entry?.[category])?.[category];
} else if (typeof categories === 'object') {
// {id: "Title", id2: "Title 2", ...}
title = categories[category];
}
if (title) {
return title;
}
// Capitalized
return category.charAt(0).toUpperCase() + category.slice(1);
function capitalize(str) {
str += '';
return str.charAt(0).toUpperCase() + str.slice(1);
}
const IDENTITY = x => x;

View File

@@ -39,7 +39,7 @@ export function outlinePlugin(options = {}) {
}
// Create a clone of the heading so we can remove links and [data-no-outline] elements from the text content
clone.querySelectorAll('a').forEach(a => a.remove());
clone.querySelectorAll('.wa-visually-hidden, [hidden], [aria-hidden="true"]').forEach(el => el.remove());
clone.querySelectorAll('[data-no-outline]').forEach(el => el.remove());
// Generate the link

View File

@@ -2,6 +2,7 @@
import { mkdir, writeFile } from 'fs/promises';
import lunr from 'lunr';
import { parse } from 'node-html-parser';
import * as path from 'path';
import { dirname, join } from 'path';
function collapseWhitespace(string) {
@@ -52,8 +53,9 @@ export function searchPlugin(options = {}) {
return content;
});
eleventyConfig.on('eleventy.after', ({ dir }) => {
const outputFilename = join(dir.output, 'search.json');
eleventyConfig.on('eleventy.after', ({ directories }) => {
const { output } = directories;
const outputFilename = path.resolve(join(output, 'search.json'));
const map = [];
const searchIndex = lunr(async function () {
let index = 0;

View File

@@ -0,0 +1,171 @@
/**
* Low-level utility to encapsulate a bit of HTML (mainly to apply certain stylesheets to it without them leaking to the rest of the page)
* Usage: <wa-scoped><template><!-- your HTML here --></template></wa-scoped>
*/
import { discover } from '/dist/webawesome.js';
const imports = new Set();
const fontFaceRules = new Set();
export default class WaScoped extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.observer = new MutationObserver(() => this.render());
this.observer.observe(this, { childList: true, subtree: true, characterData: true });
}
connectedCallback() {
this.render();
this.ownerDocument.documentElement.addEventListener('wa-color-scheme-change', e =>
this.#applyDarkMode(e.detail.dark),
);
}
render() {
this.observer.takeRecords();
this.observer.disconnect();
this.shadowRoot.innerHTML = '';
// To avoid mutating this.childNodes while iterating over it
let nodes = [];
for (let template of this.childNodes) {
// Other solutions we can try if needed: <script type="text/html">, or comment nodes
if (template instanceof HTMLTemplateElement) {
if (template.content.childNodes.length > 0) {
nodes.push(template.content.cloneNode(true));
} else if (template.childNodes.length > 0) {
// Fake template, suck its children out of the light DOM
nodes.push(...template.childNodes);
}
} else {
// Regular child, suck it out of the light DOM
nodes.push(template);
}
}
this.shadowRoot.append(...nodes);
this.#fixStyles();
this.#applyDarkMode();
discover(this.shadowRoot);
this.observer.observe(this, { childList: true, subtree: true, characterData: true });
}
#applyDarkMode(isDark = getComputedStyle(this).colorScheme === 'dark') {
// Hack to make dark mode work
// NOTE If any child nodes actually have .wa-dark, this will override it
for (let node of this.shadowRoot.children) {
node.classList.toggle('wa-dark', isDark);
}
this.classList.toggle('wa-dark', isDark);
}
/**
* @font-face does not work in shadow DOM in Chrome & FF, as of March 2025 https://issues.chromium.org/issues/41085401
* This works around this issue by traversing the shadow DOM CSS looking
* for @font-face rules or CSS imports to known font providers and copies them to the main document
*/
async #fixStyles() {
let styleElements = [...this.shadowRoot.querySelectorAll('link[rel="stylesheet"], style')];
let loadStates = styleElements.map(element => {
try {
if (element.sheet?.cssRules) {
// Already loaded
return Promise.resolve(element.sheet);
}
} catch (e) {
// CORS
return Promise.resolve(null);
}
return new Promise((resolve, reject) => {
element.addEventListener('load', e => resolve(element.sheet));
element.addEventListener('error', e => reject(null));
});
});
await Promise.allSettled(loadStates);
let fontRules = findFontFaceRules(...this.shadowRoot.styleSheets);
if (!fontRules.length) {
return;
}
let doc = this.ownerDocument;
// Why not adoptedStyleSheets? Can't have @import in those yet
let id = `wa-scoped-hoisted-fonts`;
let style = doc.head.querySelector('style#' + id);
if (!style) {
style = Object.assign(doc.createElement('style'), { id, textContent: ' ' });
doc.head.append(style);
}
let sheet = style.sheet;
for (let rule of fontRules) {
let cssText = rule.cssText;
if (rule.type === CSSRule.FONT_FACE_RULE) {
if (fontFaceRules.has(cssText)) {
continue;
}
fontFaceRules.add(cssText);
sheet.insertRule(cssText);
} else if (rule.type === CSSRule.IMPORT_RULE) {
if (imports.has(rule.href)) {
continue;
}
imports.add(rule.href);
sheet.insertRule(cssText, 0);
}
}
}
static observedAttributes = [];
}
customElements.define('wa-scoped', WaScoped);
export const WEB_FONT_HOSTS = [
'fonts.googleapis.com',
'fonts.gstatic.com',
'use.typekit.net',
'fonts.adobe.com',
'kit.fontawesome.com',
'pro.fontawesome.com',
'cdn.materialdesignicons.com',
];
function findFontFaceRules(...stylesheets) {
let ret = [];
for (let sheet of stylesheets) {
let rules;
try {
rules = sheet.cssRules;
} catch (e) {
// CORS
continue;
}
for (let rule of rules) {
if (rule.type === CSSRule.FONT_FACE_RULE) {
ret.push(rule);
} else if (rule.type === CSSRule.IMPORT_RULE) {
if (WEB_FONT_HOSTS.some(host => rule.href.includes(host))) {
ret.push(rule);
} else if (rule.styleSheet) {
ret.push(...findFontFaceRules(rule.styleSheet));
}
}
}
}
return ret;
}

View File

@@ -17,7 +17,7 @@ document.addEventListener('click', event => {
const code = codeExample.querySelector('code');
const cdnUrl = document.documentElement.dataset.cdnUrl;
const html =
`<script type="module" src="${cdnUrl}webawesome.loader.js"></script>\n` +
`<script data-fa-kit-code="b10bfbde90" type="module" src="${cdnUrl}webawesome.loader.js"></script>\n` +
`<link rel="stylesheet" href="${cdnUrl}styles/themes/default.css">\n` +
`<link rel="stylesheet" href="${cdnUrl}styles/webawesome.css">\n` +
`<link rel="stylesheet" href="${cdnUrl}styles/utilities.css">\n\n` +

View File

@@ -1,3 +1,11 @@
function debounce(func, wait) {
let timeout;
return function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
function updateResults(input) {
const filter = input.value.toLowerCase().trim();
let filtered = Boolean(filter);
@@ -18,8 +26,10 @@ function updateResults(input) {
}
}
const debouncedUpdateResults = debounce(updateResults, 300);
document.documentElement.addEventListener('input', e => {
if (e.target?.matches('#block-filter wa-input')) {
updateResults(e.target);
debouncedUpdateResults(e.target);
}
});

View File

@@ -1,12 +1,32 @@
let initialPageLoadComplete = document.readyState === 'complete';
if (!initialPageLoadComplete) {
window.addEventListener('load', () => {
initialPageLoadComplete = true;
});
}
// Helper for view transitions
export function domChange(fn, { behavior = 'smooth' } = {}) {
export function domChange(fn, { behavior = 'smooth', ignoreInitialLoad = true } = {}) {
const canUseViewTransitions =
document.startViewTransition && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Skip transitions on initial page load
if (!initialPageLoadComplete && ignoreInitialLoad) {
fn(false);
return null;
}
if (canUseViewTransitions && behavior === 'smooth') {
document.startViewTransition(fn);
const transition = document.startViewTransition(() => {
fn(true);
// Wait a brief delay before finishing the transition to prevent jumpiness
return new Promise(resolve => setTimeout(resolve, 200));
});
return transition;
} else {
fn(true);
fn(false);
return null;
}
}
@@ -100,6 +120,7 @@ const colorScheme = new ThemeAspect({
domChange(() => {
let dark = this.computedValue === 'dark';
document.documentElement.classList.toggle(`wa-dark`, dark);
document.documentElement.dispatchEvent(new CustomEvent('wa-color-scheme-change', { detail: { dark } }));
});
},
});

View File

@@ -1,3 +1,6 @@
import 'https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.10/+esm';
import { preventTurboFouce } from '/dist/webawesome.js';
if (!window.___turboScrollPositions___) {
window.___turboScrollPositions___ = {};
}
@@ -70,3 +73,4 @@ function fixDSD(e) {
window.addEventListener('turbo:before-cache', saveScrollPosition);
window.addEventListener('turbo:before-render', restoreScrollPosition);
window.addEventListener('turbo:render', restoreScrollPosition);
preventTurboFouce();

View File

@@ -4,6 +4,7 @@
@import 'outline.css';
@import 'search.css';
@import 'cera_typeface.css';
@import 'theme-icons.css';
:root {
--wa-brand-orange: #f36944;
@@ -370,10 +371,22 @@ wa-page > main:has(> .index-grid) {
.index-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(22ch, 100%), 1fr));
grid-template-columns: repeat(4, 1fr);
gap: var(--wa-space-2xl);
margin-block-end: var(--wa-space-3xl);
@media screen and (max-width: 1470px) {
grid-template-columns: repeat(3, 1fr);
}
@media screen and (max-width: 960px) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (max-width: 500px) {
grid-template-columns: repeat(1, 1fr);
}
a {
border-radius: var(--wa-border-radius-l);
text-decoration: none;
@@ -400,7 +413,6 @@ wa-page > main:has(> .index-grid) {
&::part(header) {
background-color: var(--header-background, var(--wa-color-neutral-fill-quiet));
border-bottom: none;
display: flex;
align-items: center;
justify-content: center;

View File

@@ -7,8 +7,9 @@
margin: 0 auto;
overflow: hidden;
&::part(base) {
margin-block: 10rem;
&::part(dialog) {
margin-block-start: 10vh;
margin-block-end: 0;
}
&::part(body) {
@@ -23,20 +24,20 @@
@media screen and (max-width: 900px) {
max-width: calc(100% - 2rem);
&::part(base) {
&::part(dialog) {
margin-block: 1rem;
}
#site-search-container {
max-height: none;
}
}
}
#site-search-container {
display: flex;
flex-direction: column;
max-height: calc(100vh - 20rem);
@media screen and (max-width: 900px) {
max-height: calc(100dvh - 2rem);
}
max-height: calc(100vh - 18rem);
}
/* Header */

View File

@@ -1,3 +1,61 @@
wa-card:has(> .theme-icon-host, > [slot='header'] > .theme-icon-host) {
&::part(header) {
/* We want to add a background color, so any spacing needs to go on .theme-icon */
flex: 1;
padding: 0;
min-block-size: 0;
}
[slot='header'] {
width: 100%;
}
}
.theme-icon-host,
.palette-icon-host {
flex: 1;
border-radius: inherit;
&[slot='header'],
[slot='header']:has(&) {
flex: 1;
border-radius: inherit;
}
}
.palette-icon {
display: grid;
grid-template-columns: repeat(var(--hues, 9), 1fr);
gap: var(--wa-space-3xs);
min-width: 20ch;
min-height: 9ch;
align-content: center;
.swatch {
height: 0.7em;
background: var(--color);
border-radius: var(--wa-border-radius-s);
&[data-suffix=''] {
height: 1.1em;
}
}
}
.theme-icon {
min-width: 18ch;
padding: var(--wa-space-xs) var(--wa-space-m);
border-radius: inherit;
box-sizing: border-box;
h2,
h3,
p {
margin-block: 0;
padding: 0;
}
}
.theme-color-icon {
display: grid;
gap: var(--wa-space-xs);
@@ -25,10 +83,50 @@
display: flex;
flex-direction: column;
gap: var(--wa-space-xs);
}
h3,
p {
margin-block: 0;
padding: 0;
.theme-overall-icon {
display: flex;
flex-flow: column;
gap: var(--wa-space-xs);
justify-content: center;
width: 100%;
min-height: 7.5rem;
box-sizing: border-box;
background: var(--wa-color-surface-lowered);
.row {
display: flex;
gap: var(--wa-space-xs);
align-items: center;
justify-content: space-between;
}
.row-2 {
display: grid;
grid-template-columns: 1fr auto;
contain: inline-size;
width: 100%;
wa-input {
min-width: 1em;
}
}
.swatches {
display: flex;
gap: var(--wa-space-3xs);
> div {
width: 1.25rem;
height: 1.25rem;
border-radius: var(--wa-border-radius-s);
background: var(--wa-color-fill-loud);
color: var(--wa-color-on-loud);
&.wa-brand {
width: 2.5rem;
}
}
}
}

View File

@@ -15,9 +15,9 @@ icon: card
<strong>Mittens</strong><br />
This kitten is as cute as he is playful. Bring him home today!<br />
<small>6 weeks old</small>
<small class="wa-caption-m">6 weeks old</small>
<div slot="footer">
<div slot="footer" class="wa-split">
<wa-button variant="brand" pill>More Info</wa-button>
<wa-rating label="Rating"></wa-rating>
</div>
@@ -27,16 +27,6 @@ icon: card
.card-overview {
width: 300px;
}
.card-overview small {
color: var(--wa-color-text-quiet);
}
.card-overview [slot='footer'] {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
```
@@ -65,9 +55,9 @@ If using SSR, you need to also use the `with-header` attribute to add a header t
```html {.example}
<wa-card with-header class="card-header">
<div slot="header">
<div slot="header" class="wa-split">
Header Title
<wa-icon-button name="gear" variant="solid" label="Settings"></wa-icon-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!
@@ -78,19 +68,9 @@ If using SSR, you need to also use the `with-header` attribute to add a header t
max-width: 300px;
}
.card-header [slot='header'] {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header h3 {
margin: 0;
}
.card-header wa-icon-button {
font-size: var(--wa-font-size-m);
}
</style>
```
@@ -103,7 +83,7 @@ If using SSR, you need to also use the `with-footer` attribute to add a footer t
<wa-card with-footer class="card-footer">
This card has a footer. You can put all sorts of things in it!
<div slot="footer">
<div slot="footer" class="wa-split">
<wa-rating></wa-rating>
<wa-button variant="brand">Preview</wa-button>
</div>
@@ -113,12 +93,6 @@ If using SSR, you need to also use the `with-footer` attribute to add a footer t
.card-footer {
max-width: 300px;
}
.card-footer [slot='footer'] {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
```
@@ -153,7 +127,7 @@ Use the `size` attribute to change a card's size.
<wa-card with-footer size="small">
This is a small card.
<footer slot="footer" class="wa-flank">
<footer slot="footer" class="wa-split">
<wa-button variant="brand" pill>More Info</wa-button>
<wa-rating></wa-rating>
</footer>
@@ -162,7 +136,7 @@ Use the `size` attribute to change a card's size.
<wa-card with-footer size="medium">
This is a medium card (default).
<footer slot="footer" class="wa-flank">
<footer slot="footer" class="wa-split">
<wa-button variant="brand" pill>More Info</wa-button>
<wa-rating></wa-rating>
</footer>
@@ -171,14 +145,39 @@ Use the `size` attribute to change a card's size.
<wa-card with-footer size="large">
This is a large card.
<footer slot="footer" class="wa-flank">
<footer slot="footer" class="wa-split">
<wa-button variant="brand" pill>More Info</wa-button>
<wa-rating></wa-rating>
</footer>
</wa-card>
</div>
```
<style>
</style>
### Appearance
Use the `appearance` attribute to change the card's visual appearance.
```html {.example}
<div class="wa-grid">
<wa-card>
<img
slot="image"
src="https://images.unsplash.com/photo-1559209172-0ff8f6d49ff7?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=500&q=80"
alt="A kitten sits patiently between a terracotta pot and decorative grasses."
/>
<div slot="header">Outlined (default)</div>
Card content.
</wa-card>
{% for appearance in ['outlined filled', 'outlined accent', 'plain', 'filled', 'accent'] -%}
<wa-card appearance="{{ appearance }}">
<img
slot="image"
src="https://images.unsplash.com/photo-1559209172-0ff8f6d49ff7?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=500&q=80"
alt="A kitten sits patiently between a terracotta pot and decorative grasses."
/>
<div slot="header">{{ appearance | capitalize }}</div>
Card content.
</wa-card>
{%- endfor %}
</div>
```

View File

@@ -77,6 +77,31 @@ The details component automatically adapts to right-to-left languages:
</wa-details>
```
### Appearance
Use the `appearance` attribute to change the elements visual appearance.
```html {.example}
<div class="wa-stack">
<wa-details summary="Outlined (default)">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</wa-details>
<wa-details summary="Filled" appearance="filled">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</wa-details>
<wa-details summary="Filled + Outlined" appearance="filled outlined">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</wa-details>
<wa-details summary="Plain" appearance="plain">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</wa-details>
</div>
```
### Grouping Details
Details are designed to function independently, but you can simulate a group or "accordion" where only one is shown at a time by listening for the `wa-show` event.

View File

@@ -2,13 +2,10 @@
title: Components
description: Components are the essential building blocks to create intuitive, cohesive experiences. Browse the library of customizable, framework-friendly web components included in Web Awesome.
layout: overview
categories:
- actions
- feedback: 'Feedback & Status'
- imagery
- inputs
- navigation
- organization
- helpers: 'Utilities'
override:tags: []
categories:
tags: [actions, feedback, imagery, inputs, navigation, organization, helpers]
titles:
feedback: 'Feedback & Status'
helpers: 'Utilities'
---

View File

@@ -1,10 +1,80 @@
/**
* Global data for all pages
*/
import { sort } from '../_utils/filters.js';
export default {
eleventyComputed: {
children(data) {
let mainTag = data.tags?.[0];
let collection = data.collections[mainTag] ?? [];
// Default parent. Can be overridden by explicitly setting parent in the data.
// parent can refer to either an ancestor page in the URL or another page in the same directory
parent(data) {
let { parent, page } = data;
return collection.filter(item => item.data.parent === data.page.fileSlug);
if (parent) {
return parent;
}
return page.url.split('/').filter(Boolean).at(-2);
},
parentUrl(data) {
let { parent, page } = data;
return getParentUrl(page.url, parent);
},
parentItem(data) {
let { parentUrl } = data;
return data.collections.all.find(item => item.url === parentUrl);
},
children(data) {
let { collections, page, parentOf } = data;
if (parentOf) {
return collections[parentOf];
}
let collection = collections.all ?? [];
let url = page.url;
let ret = collection.filter(item => {
return item.data.parentUrl === url;
});
sort(ret);
return ret;
},
},
};
function getParentUrl(url, parent) {
let parts = url.split('/').filter(Boolean);
let ancestorIndex = parts.findLastIndex(part => part === parent);
let retParts = parts.slice();
if (ancestorIndex > -1) {
// parent is an ancestor
retParts.splice(ancestorIndex + 1);
} else {
// parent is a sibling in the same directory
retParts.splice(-1, 1, parent);
}
let ret = retParts.join('/');
if (url.startsWith('/')) {
ret = '/' + ret;
}
if (!retParts.at(-1).includes('.') && !ret.endsWith('/')) {
// If no extension, make sure to end with a slash
ret += '/';
}
if (ret === '/docs/') {
ret = '/';
}
return ret;
}

View File

@@ -37,10 +37,6 @@ This snippet includes three parts:
Now you can [start using Web Awesome!](/docs/usage)
:::info
While convenient, autoloading may lead to a [Flash of Undefined Custom Elements](https://www.abeautifulsite.net/posts/flash-of-undefined-custom-elements/). The linked article describes some ways to alleviate it.
:::
---
## Using Font Awesome Kit Codes

View File

@@ -2,6 +2,7 @@
title: Layout
description: Layout components and utility classes help you organize content that can adapt to any device or screen size. See the [installation instructions](#installation) to use Web Awesome's layout tools in your project.
layout: overview
parentOf: layout
categories: ["components", "utilities"]
override:tags: []
---
@@ -22,4 +23,4 @@ Or, you can choose to import _only_ the utilities:
```html
<link rel="stylesheet" href="{% cdnUrl 'styles/utilities.css' %}" />
```
{% endmarkdown %}
{% endmarkdown %}

View File

@@ -33,7 +33,7 @@ Use the [variant utility classes](../utilities/color.md) to set the button's sem
### Appearance
Use the [appearance utility classes](../utilities/appearance.md) to change the button's visual appearance:
Use the [appearance utility classes](/docs/utilities/appearance) to change the button's visual appearance:
```html {.example}
<div style="margin-block-end: 1rem;">

View File

@@ -57,7 +57,7 @@ Use the [variant utility classes](../utilities/color.md) to set the callout's co
### Appearance
Use the [appearance utility classes](../utilities/appearance.md) to change the callout's visual appearance (the default is `outlined filled`).
Use the [appearance utility classes](/docs/utilities/appearance) to change the callout's visual appearance (the default is `outlined filled`).
```html {.example}
<article class="wa-callout wa-brand wa-outlined wa-accent">

View File

@@ -19,6 +19,35 @@ file: styles/native/details.css
## Examples
### Appearance
Use the [appearance utility classes](/docs/utilities/appearance) to change the element's visual appearance:
```html {.example}
<div class="wa-stack">
<details>
<summary>Outlined (default)</summary>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</details>
<details class="wa-filled">
<summary>Filled</summary>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</details>
<details class="wa-filled wa-outlined">
<summary>Filled + Outlined</summary>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</details>
<details class="wa-plain">
<summary>Plain</summary>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
</details>
</div>
```
### Right-to-Left Languages
The details styling automatically adapts to right-to-left languages:

View File

@@ -5,6 +5,6 @@ layout: overview
override:tags: []
forTag: palette
categories:
tags: [other, pro]
other: Free
pro: Pro
---

View File

@@ -2,7 +2,5 @@
title: Patterns
description: Patterns are reusable solutions to common design problems.
layout: overview
categories: ["e-commerce"]
listChildren: true
override:tags: []
---

View File

@@ -14,9 +14,35 @@ During the alpha period, things might break! We take breaking changes very serio
## Next
- Fixed the search dialog's styles so it doesn't jump around as you search
- Removed close watcher logic to backdrop hide animation bugs in `<wa-dialog>` and `<wa-drawer>`; this logic is already handled and we'll revisit `CloseWatcher` when browser support is better and behaviors are consistent
- Fixed a bug that caused dropdowns inside button groups to show with a vertical misalignment
- Revert `<wa-dialog>` structure and CSS to fix clipped content in dialogs (WA-A #123) and light dismiss in iOS Safari (WA-A #201)
- Fixed a bug in `<wa-progress>` that prevented Safari from animation progress changes
- Fixed the missing indeterminate icon in native checkbox styles
- Fixed a bug where changing a `<wa-option>` label wouldn't update the display label in `<wa-select>`
- Fixed a bug in `<wa-radio>` where elements would stack instead of display inline
### Enhancements
- Added `appearance` to [`<wa-details>`](/docs/components/details) and [`<wa-card>`](/docs/components/card) and support for the [appearance utilities](/docs/utilities/appearance/) in the [`<details>` native styles](/docs/native/details).
- Added an `orange` scale to all color palettes
- Added the `.wa-cloak` utility to prevent FOUCE
- Added the `allDefined()` utility for awaiting component registration
- Added default spacing to icons slotted into `<wa-tab>`
- Fixed `wa-pill` class for text fields
### Bugfixes
- Specifying inherited CSS properties on `<wa-tooltip>` now works as expected ([thanks Dennis!](https://github.com/shoelace-style/webawesome-alpha/discussions/203))
- Fixed a bug in `<wa-select>` that made it hard to use with VueJS, Svelte, and many other frameworks
- Fixed the `wa-pill` class for text fields
- Fixed `pill` style for `<wa-input>` elements
- Fixed a bug in `<wa-color-picker>` that prevented light dismiss from working when clicking immediately above the color picker dropdown
- Fixed a bug in `<wa-select multiple>` that sometimes resulted in empty `<div>` elements being output
- Fixed a bug in `<wa-radio-button>` that prevented the pill effect from applying properly
- Fixed a bug in `<wa-radio-button>` that prevented active buttons from receiving the correct styles
- Fixed a bug in `<wa-button>` that prevented the focus ring from showing in Safari
## 3.0.0-alpha.11

View File

@@ -31,8 +31,7 @@ If you're customizing the default dark styles, scope your styles to the followin
```css
.wa-dark,
.wa-invert,
:is(:host-context(.wa-dark)) {
.wa-invert {
/* your custom styles here */
}
```

View File

@@ -1,13 +1,13 @@
---
title: Themes
description: Themes are collections of design tokens that thread through every Web Awesome component and pattern.
description: Themes are collections of design tokens that thread through every Web Awesome component and pattern.
Themes play a crucial role in [customizing Web Awesome](/docs/customizing).
layout: overview
override:tags: []
forTag: theme
categories:
tags: [other, pro]
other: Free
pro: Pro
---
<div class="max-line-length">
@@ -30,7 +30,7 @@ In pre-made themes, we use a light color scheme by default.
Additionally, styles may be scoped to the `:root` selector to be activated automatically.
For pre-made themes, *all* custom properties are scoped to `:root`, the theme class, and `wa-light`.
Finally, we scope themes to `:host` and `:host-context()` to ensure the styles are applied to the shadow roots of custom elements.
Finally, we scope themes to `:host` to ensure the styles are applied to the shadow roots of custom elements.
For example, the default theme is set up like this:
@@ -44,8 +44,7 @@ For example, the default theme is set up like this:
}
.wa-dark,
.wa-invert,
:host-context(.wa-dark) {
.wa-invert {
/* subset of CSS custom properties for a dark color scheme */
}
```

View File

@@ -3,6 +3,7 @@
"wide": true,
"tags": ["themes", "theme"],
"brand": "blue",
"icon": "theme",
"eleventyComputed": {
"file": "styles/themes/{{ page.fileSlug }}.css"
}

View File

@@ -3,4 +3,5 @@ title: Design Tokens
description: A theme is a collection of predefined CSS custom properties that control global styles from color to shadows. These custom properties thread through all Web Awesome components for a consistent look and feel.
layout: overview
override:tags: []
categories: {tags: true}
---

View File

@@ -8,6 +8,30 @@ Web Awesome components are just regular HTML elements, or [custom elements](http
If you're new to custom elements, often referred to as "web components," this section will familiarize you with how to use them.
## Awaiting Registration
Unlike traditional frameworks, custom elements don't have a centralized initialization phase. This means you need to verify that a custom element has been properly registered before attempting to interact with its properties or methods.
You can use the [`customElements.whenDefined()`](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/whenDefined) method to ensure a specific component is ready:
```ts
await customElements.whenDefined('wa-button');
// <wa-button> is ready to use!
const button = document.querySelector('wa-button');
```
When working with multiple components, checking each one individually can become tedious. For convenience, Web Awesome provides the `allDefined()` function which automatically detects and waits for all Web Awesome components in the DOM to be initialized before resolving.
```ts
import { allDefined } from '/dist/webawesome.js';
// Waits for all Web Awesome components in the DOM to be registered
await allDefined();
// All Web Awesome components on the page are ready!
```
## Attributes & Properties
Many components have properties that can be set using attributes. For example, buttons accept a `size` attribute that maps to the `size` property which dictates the button's size.
@@ -88,49 +112,6 @@ For example, `<button>` and `<wa-button>` both have a `type` attribute, but the
**Don't make assumptions about a component's API!** To prevent unexpected behaviors, please take the time to review the documentation and make sure you understand what each attribute, property, method, and event is intended to do.
:::
## Waiting for Components to Load
Web components are registered with JavaScript, so depending on how and when you load Web Awesome, you may notice a [Flash of Undefined Custom Elements (FOUCE)](https://www.abeautifulsite.net/posts/flash-of-undefined-custom-elements/) when the page loads. There are a couple ways to prevent this, both of which are described in the linked article.
One option is to use the [`:defined`](https://developer.mozilla.org/en-US/docs/Web/CSS/:defined) CSS pseudo-class to "hide" custom elements that haven't been registered yet. You can scope it to specific tags or you can hide all undefined custom elements as shown below.
```css
:not(:defined) {
visibility: hidden;
}
```
As soon as a custom element is registered, it will immediately appear with all of its styles, effectively eliminating FOUCE. Note the use of `visibility: hidden` instead of `display: none` to reduce shifting as elements are registered. The drawback to this approach is that custom elements can potentially appear one by one instead of all at the same time.
Another option is to use [`customElements.whenDefined()`](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/whenDefined), which returns a promise that resolves when the specified element gets registered. You'll probably want to use it with [`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled) in case an element fails to load for some reason.
A clever way to use this method is to hide the `<body>` with `opacity: 0` and add a class that fades it in as soon as all your custom elements are defined.
```html
<style>
body {
opacity: 0;
}
body.ready {
opacity: 1;
transition: 0.25s opacity;
}
</style>
<script type="module">
await Promise.allSettled([
customElements.whenDefined('wa-button'),
customElements.whenDefined('wa-card'),
customElements.whenDefined('wa-rating')
]);
// Button, card, and rating are registered now! Add
// the `ready` class so the UI fades in.
document.body.classList.add('ready');
</script>
```
## Component Rendering and Updating
Web Awesome components are built with [Lit](https://lit.dev/), a tiny library that makes authoring custom elements easier, more maintainable, and a lot of fun! As a Web Awesome user, here is some helpful information about rendering and updating you should probably be aware of.

View File

@@ -0,0 +1,32 @@
---
title: Reduce FOUCE
description: Utility to improve the loading experience by hiding non-prerendered custom elements until they are registered.
file: styles/utilities/fouce.css
icon: spinner
---
While convenient, autoloading can lead to a [Flash of Undefined Custom Elements](https://www.abeautifulsite.net/posts/flash-of-undefined-custom-elements/).
The [FOUCE style utility](/docs/utilities/fouce/#opting-in) (which is automatically applied if you use the [Web Awesome utilities](/docs/utilities/)) takes care of hiding custom elements until they and their contents have been registered, up to a maximum of two seconds.
In many cases, this is not enough, and you may wish to hide a broader wrapper element or even the entire page until all WA elements within it have loaded. To do that, you can add the `wa-reduce-fouce` class to any element on the page or even apply it to the whole page by placing the class on the `<html>` element.
```html
<html class="wa-cloak">
...
</html>
```
As soon as all elements are registered _or_ after two seconds have elapsed, the autoloader will show the page. The two-second timeout prevents blank screens from persisting on slow networks and pages that have errors.
:::details Are you using Turbo in your app?
If you're using [Turbo](https://turbo.hotwired.dev/) to serve a multi-page application (MPA) as a single page application (SPA), you might notice FOUCE when navigating from page to page. This is because Turbo renders the new page's content before the autoloader has a change to register new components.
The following function acts as a middleware to ensure components are registered _before_ the page shows, eliminating FOUCE for page-to-page navigation with Turbo.
```js
import { preventTurboFouce } from '/dist/webawesome.js';
preventTurboFouce();
```
:::

View File

@@ -1,131 +0,0 @@
---
title: Reduce FOUCE
description: Utility to improve the loading experience by hiding non-prerendered custom elements until they are registered.
file: styles/utilities/fouce.css
icon: spinner
---
{% markdown %}
No class is needed to use this utility, it will be applied automatically as long as it its CSS is included.
Here is a comparison of the loading experience with and without this utility,
with a simulated slow loading time:
{% endmarkdown %}
<div class="wa-split wa-align-items-end">
<strong>Normal loading</strong>
<wa-button onclick="document.querySelectorAll('iframe').forEach(iframe => iframe.srcdoc = iframe.srcdoc)">
<wa-icon name="refresh"></wa-icon>
Refresh
</wa-button>
<strong>With FOUCE reduction</strong>
</div>
{% set sample_card %}
<link id="theme-stylesheet" rel="stylesheet" href="/dist/styles/themes/default.css" render="blocking" fetchpriority="high">
<link rel="stylesheet" href="/dist/styles/webawesome.css">
<link rel="stylesheet" href="/dist/styles/forms.css">
<script type=module>
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const loadScript = src => new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.type = "module";
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
await delay(500);
await loadScript("/dist/components/button/button.js");
await delay(500);
await loadScript("/dist/components/card/card.js");
await delay(500);
await loadScript("/dist/components/rating/rating.js");
</script>
<wa-card with-footer with-image class="card-overview">
<img
slot="image"
src="https://images.unsplash.com/photo-1559209172-0ff8f6d49ff7?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=500&q=80"
alt="A kitten sits patiently between a terracotta pot and decorative grasses."
/>
<strong>Mittens</strong><br />
This kitten is as cute as he is playful. Bring him home today!<br />
<small>6 weeks old</small>
<div slot="footer">
<wa-button variant="brand" pill>More Info</wa-button>
<wa-rating></wa-rating>
</div>
</wa-card>
<style>
.card-overview small {
color: var(--wa-color-text-quiet);
}
.card-overview [slot=footer] {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
{% endset %}
<div class="iframes">
<iframe srcdoc='<body class="wa-fouce-off">{{ sample_card }}</body>'></iframe>
<iframe srcdoc='{{ sample_card }}'></iframe>
</div>
<style>
.iframes {
display: flex;
gap: var(--wa-space-m);
margin-top: var(--wa-space-l);
iframe {
flex: 1;
height: 60ch;
}
}
</style>
{% markdown %}
## How does it work?
The utility consists of a timeout (`2s` by default) and a fade duration (`200ms` by default).
- If the element is _ready_ before the timeout, it will appear immediately.
- If it takes longer than _timeout_ + _fade_, it will fade in over the fade duration.
- If it takes somewhere between _timeout_ and _timeout_ + _fade_, you will get an interrupted fade.
An element is considered ready when both of these are true:
1. Either It has been registered or has a `did-ssr` attribute (indicating it was pre-rendered)
2. If its a Web Awesome component, its contents are also ready
## Customization
You can use the following CSS variables to customize the behavior:
| Variable | Description | Default |
| --- | --- | --- |
| `--wa-fouce-fade` | The transition duration for the fade effect. | `200ms` |
| `--wa-fouce-timeout` | The timeout after which elements will appear even if not registered | `2s` |
The fade duration cannot be longer than the timeout.
This means that you can disable FOUCE reduction on an element by setting `--wa-fouce-timeout: 0s`.
For example, if instead of `did-ssr` you used an `ssr` attribute to mark elements that were pre-rendered, you can do this to get them to appear immediately:
```css
[ssr] {
--wa-fouce-timeout: 0s;
}
```
You can also opt-out from FOUCE reduction for an element and its contents by adding the `.wa-fouce-off` class to it.
Applying this class to the root element will disable the utility for the entire page.
{% endmarkdown %}

View File

@@ -52,8 +52,8 @@
"start:alpha": "node scripts/build.js --alpha --develop",
"publish-alpha-cdn": "./publish-alpha-cdn.sh",
"create": "plop --plopfile scripts/plop/plopfile.js",
"test": "web-test-runner --group default",
"test:component": "web-test-runner -- --watch --group",
"test": "CSR_ONLY=\"true\" web-test-runner --group default",
"test:component": "CSR_ONLY=\"true\" web-test-runner -- --watch --group",
"test:contrast": "cd src/styles/color && node contrast.test.js",
"test:watch": "web-test-runner --watch --group default",
"prettier": "prettier --check --log-level=warn .",

View File

@@ -8,7 +8,6 @@ import { replace } from 'esbuild-plugin-replace';
import { mkdir, readFile } from 'fs/promises';
import getPort, { portNumbers } from 'get-port';
import { globby } from 'globby';
import nunjucks from 'nunjucks';
import ora from 'ora';
import { dirname, join, relative } from 'path';
import process from 'process';
@@ -345,35 +344,6 @@ if (isDeveloping) {
'/dist/': './dist-cdn/',
},
},
middleware: [
function simulateWebawesomeApp(req, res, next) {
// Accumulator for strings so we can pass them through nunjucks a second time similar to how the webawesome-app
// will be running nunjucks twice.
const finalString = [];
const encoding = 'utf-8';
const _write = res.write;
res.write = function (chunk, encoding) {
finalString.push(chunk.toString());
};
const _end = res.end;
res.end = function (...args) {
const transformedStr = nunjucks.renderString(finalString.join(''), {
// Stub the server EJS shortcodes.
server: {
head: '',
loginOrAvatar: '',
flashes: '',
},
});
_write.call(res, transformedStr, encoding);
_end.call(res, ...args);
};
next();
},
],
callbacks: {
ready: (_err, instance) => {
// 404 errors

View File

@@ -5,15 +5,16 @@
--spacing: var(--wa-space);
--border-width: var(--wa-panel-border-width);
--border-color: var(--wa-color-surface-border);
--outlined-background-color: var(--wa-color-surface-default);
--outlined-border-color: var(--wa-color-surface-border);
--border-radius: var(--wa-panel-border-radius);
--inner-border-radius: calc(var(--border-radius) - var(--border-width));
--inner-border-color: var(--outlined-border-color);
display: flex;
flex-direction: column;
background-color: var(--wa-color-surface-default);
border-color: var(--border-color);
background-color: var(--background-color, var(--wa-color-surface-default));
border-color: var(--border-color, var(--wa-color-surface-border));
border-radius: var(--border-radius);
border-style: var(--wa-panel-border-style);
box-shadow: var(--wa-shadow-s);
@@ -21,6 +22,20 @@
color: var(--wa-color-text-normal);
}
:host(:is([appearance~='accent'], .wa-accent)) {
color: var(--text-color, var(--wa-color-text-normal));
}
:host([appearance~='filled']),
:host(.wa-filled) {
--inner-border-color: oklab(from var(--outlined-border-color) l a b / 65%);
}
:host([appearance='plain']) {
--inner-border-color: transparent;
box-shadow: none;
}
/* Take care of top and bottom radii */
.image,
:host(:not([with-image])) .header,
@@ -46,10 +61,19 @@
}
}
/* Round all corners for plain appearance */
:host([appearance='plain']) .image {
border-radius: var(--inner-border-radius);
&::slotted(img) {
border-radius: inherit !important;
}
}
.header {
display: block;
border-block-end-style: inherit;
border-block-end-color: var(--border-color);
border-block-end-color: var(--inner-border-color);
border-block-end-width: var(--border-width);
padding: calc(var(--spacing) / 2) var(--spacing);
}
@@ -62,7 +86,7 @@
.footer {
display: block;
border-block-start-style: inherit;
border-block-start-color: var(--border-color);
border-block-start-color: var(--inner-border-color);
border-block-start-width: var(--border-width);
padding: var(--spacing);
}

View File

@@ -2,6 +2,7 @@ import { html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { HasSlotController } from '../../internal/slot.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import appearanceStyles from '../../styles/utilities/appearance.css';
import sizeStyles from '../../styles/utilities/size.css';
import styles from './card.css';
@@ -22,19 +23,24 @@ import styles from './card.css';
* @csspart footer - The container that wraps the card's footer.
*
* @cssproperty [--border-radius=var(--wa-panel-border-radius)] - The radius for the card's corners. Expects a single value.
* @cssproperty [--border-color=var(--wa-color-surface-border)] - The color of the card's borders, including inner borders. Expects a single value.
* @cssproperty [--border-color=var(--wa-color-surface-border)] - The color of the card's borders. Expects a single value.
* @cssproperty [--inner-border-color=var(--wa-color-surface-border)] - The color of the card's inner borders, e.g. those separating headers and footers from the main content. Expects a single value.
* @cssproperty [--border-width=var(--wa-panel-border-width)] - The width of the card's borders. Expects a single value.
* @cssproperty [--spacing=var(--wa-space)] - The amount of space around and between sections of the card. Expects a single value.
*/
@customElement('wa-card')
export default class WaCard extends WebAwesomeElement {
static shadowStyle = [sizeStyles, styles];
static shadowStyle = [sizeStyles, appearanceStyles, styles];
private readonly hasSlotController = new HasSlotController(this, 'footer', 'header', 'image');
/** The component's size. Will be inherited by any descendants with a `size` attribute. */
@property({ reflect: true, initial: 'medium' }) size: 'small' | 'medium' | 'large' | 'inherit' = 'inherit';
/** The card's visual appearance. */
@property({ reflect: true })
appearance: 'accent' | 'filled' | 'outlined' | 'plain' = 'outlined';
/** Renders the card with a header. Only needed for SSR, otherwise is automatically added. */
@property({ attribute: 'with-header', type: Boolean, reflect: true }) withHeader = false;

View File

@@ -9,6 +9,7 @@ import { getTargetElement, waitForEvent } from '../../internal/event.js';
import { watch } from '../../internal/watch.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import nativeStyles from '../../styles/native/details.css';
import appearanceStyles from '../../styles/utilities/appearance.css';
import { LocalizeController } from '../../utilities/localize.js';
import '../icon/icon.js';
import styles from './details.css';
@@ -45,7 +46,7 @@ import styles from './details.css';
*/
@customElement('wa-details')
export default class WaDetails extends WebAwesomeElement {
static shadowStyle = [nativeStyles, styles];
static shadowStyle = [appearanceStyles, nativeStyles, styles];
private detailsObserver: MutationObserver;
private readonly localize = new LocalizeController(this);
@@ -67,6 +68,9 @@ export default class WaDetails extends WebAwesomeElement {
/** Disables the details so it can't be toggled. */
@property({ type: Boolean, reflect: true }) disabled = false;
/** The element's visual appearance. */
@property({ reflect: true }) appearance: 'filled' | 'outlined' | 'plain' = 'outlined';
firstUpdated() {
this.body.style.height = this.open ? 'auto' : '0';
if (this.open) {

View File

@@ -1,24 +1,89 @@
:host {
--background-color: var(--wa-color-surface-raised);
--border-radius: var(--wa-panel-border-radius);
--box-shadow: var(--wa-shadow-l);
--width: 31rem;
--spacing: var(--wa-space-xl);
--show-duration: 200ms;
--hide-duration: 200ms;
display: contents;
}
:host(:not([open])) {
display: none;
}
dialog {
width: inherit;
max-width: inherit;
max-height: inherit;
background-color: inherit;
border-radius: inherit;
border: inherit;
box-shadow: inherit;
padding: inherit;
:host([open]) {
display: block;
}
.dialog {
display: flex;
flex-direction: column;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: var(--width);
max-width: calc(100% - var(--wa-space-2xl));
max-height: calc(100% - var(--wa-space-2xl));
background-color: var(--background-color);
border-radius: var(--border-radius);
border: none;
box-shadow: var(--box-shadow);
padding: 0;
margin: auto;
transition: inherit;
&.show {
animation: show-dialog var(--show-duration) ease;
&::backdrop {
animation: show-backdrop var(--show-duration, 200ms) ease;
}
}
&.hide {
animation: show-dialog var(--hide-duration) ease reverse;
&::backdrop {
animation: show-backdrop var(--hide-duration, 200ms) ease reverse;
}
}
&.pulse {
animation: pulse 250ms ease;
}
}
.dialog:focus {
outline: none;
}
/* Ensure there's enough vertical padding for phones that don't update vh when chrome appears (e.g. iPhone) */
@media screen and (max-width: 420px) {
.dialog {
max-height: 80vh;
}
}
.dialog--open {
display: flex;
opacity: 1;
}
.header {
flex: 0 0 auto;
display: flex;
flex-wrap: nowrap;
padding: var(--spacing);
padding-block-end: 0;
}
.title {
align-self: center;
flex: 1 1 auto;
font-family: inherit;
font-size: var(--wa-font-size-l);
font-weight: var(--wa-font-weight-heading);
line-height: var(--wa-line-height-condensed);
margin: 0;
}
.header-actions {
@@ -28,13 +93,81 @@ dialog {
flex-wrap: wrap;
justify-content: end;
gap: var(--wa-space-2xs);
margin-inline-start: auto;
padding-inline-start: var(--spacing);
}
wa-icon-button,
::slotted(wa-icon-button) {
flex: 0 0 auto;
display: flex;
align-items: center;
font-size: var(--wa-font-size-m);
.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 {
flex: 1 1 auto;
display: block;
padding: var(--spacing);
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.footer {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
gap: var(--wa-space-xs);
justify-content: end;
padding: var(--spacing);
padding-block-start: 0;
}
.footer ::slotted(wa-button:not(:first-of-type)) {
margin-inline-start: var(--wa-spacing-xs);
}
.dialog::backdrop {
/*
NOTE: the ::backdrop element doesn't inherit properly in Safari yet, but it will in 17.4! At that time, we can
remove the fallback values here.
*/
background-color: var(--wa-color-overlay-modal, rgb(0 0 0 / 0.25));
}
@keyframes pulse {
0% {
scale: 1;
}
50% {
scale: 1.02;
}
100% {
scale: 1;
}
}
@keyframes show-dialog {
from {
opacity: 0;
scale: 0.8;
}
to {
opacity: 1;
scale: 1;
}
}
@keyframes show-backdrop {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (forced-colors: active) {
.dialog {
border: solid 1px white;
}
}

View File

@@ -1,5 +1,6 @@
import { html, isServer } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { customElement, property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { WaAfterHideEvent } from '../../events/after-hide.js';
import { WaAfterShowEvent } from '../../events/after-show.js';
import { WaHideEvent } from '../../events/hide.js';
@@ -8,7 +9,6 @@ import { animateWithClass } from '../../internal/animate.js';
import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll.js';
import { watch } from '../../internal/watch.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import dialogStyles from '../../styles/native/dialog.css';
import { LocalizeController } from '../../utilities/localize.js';
import '../icon-button/icon-button.js';
import styles from './dialog.css';
@@ -35,7 +35,6 @@ import styles from './dialog.css';
* behavior such as data loss.
* @event wa-after-hide - Emitted after the dialog closes and all animations are complete.
*
* @csspart base - The inner `<dialog>` used to render this component.
* @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-icon-button>`.
* @csspart title - The dialog's title.
@@ -44,16 +43,22 @@ import styles from './dialog.css';
* @csspart body - The dialog's body.
* @csspart footer - The dialog's footer.
*
* @cssproperty --background-color - The dialog's background color.
* @cssproperty --border-radius - The radius of the dialog's corners.
* @cssproperty --box-shadow - The shadow effects around the edges of the dialog.
* @cssproperty --spacing - The amount of space around and between the dialog's content.
* @cssproperty --width - The preferred width of the dialog. Note that the dialog will shrink to accommodate smaller screens.
* @cssproperty [--show-duration=200ms] - The animation duration when showing the dialog.
* @cssproperty [--hide-duration=200ms] - The animation duration when hiding the dialog.
*/
@customElement('wa-dialog')
export default class WaDialog extends WebAwesomeElement {
static shadowStyle = [dialogStyles, styles];
static shadowStyle = styles;
private readonly localize = new LocalizeController(this);
private originalTrigger: HTMLElement | null;
private closeWatcher: CloseWatcher | null;
@query('dialog') dialog: HTMLDialogElement;
@query('.dialog') dialog: HTMLDialogElement;
/**
* Indicates whether or not the dialog is open. You can toggle this attribute to show and hide the dialog, or you can
@@ -76,9 +81,6 @@ export default class WaDialog extends WebAwesomeElement {
/** When enabled, the dialog will be closed when the user clicks outside of it. */
@property({ attribute: 'light-dismiss', type: Boolean }) lightDismiss = false;
@state()
hasOpened = this.open;
firstUpdated() {
if (this.open) {
this.addOpenListeners();
@@ -100,12 +102,14 @@ export default class WaDialog extends WebAwesomeElement {
if (waHideEvent.defaultPrevented) {
this.open = true;
animateWithClass(this.dialog, 'wa-dialog-pulse');
animateWithClass(this.dialog, 'pulse');
return;
}
this.removeOpenListeners();
await animateWithClass(this.dialog, 'hide');
this.open = false;
this.dialog.close();
unlockBodyScrolling(this);
@@ -120,16 +124,7 @@ export default class WaDialog extends WebAwesomeElement {
}
private addOpenListeners() {
if ('CloseWatcher' in window) {
this.closeWatcher?.destroy();
this.closeWatcher = new CloseWatcher();
this.closeWatcher.onclose = () => {
this.requestClose(this.dialog);
};
} else {
this.closeWatcher?.destroy();
document.addEventListener('keydown', this.handleDocumentKeyDown);
}
document.addEventListener('keydown', this.handleDocumentKeyDown);
}
private removeOpenListeners() {
@@ -161,7 +156,7 @@ export default class WaDialog extends WebAwesomeElement {
if (this.lightDismiss) {
this.requestClose(this.dialog);
} else {
await animateWithClass(this.dialog, 'wa-dialog-pulse');
await animateWithClass(this.dialog, 'pulse');
}
}
}
@@ -198,7 +193,6 @@ export default class WaDialog extends WebAwesomeElement {
this.addOpenListeners();
this.originalTrigger = document.activeElement as HTMLElement;
this.open = true;
this.hasOpened = true;
this.dialog.showModal();
lockBodyScrolling(this);
@@ -211,20 +205,28 @@ export default class WaDialog extends WebAwesomeElement {
}
});
await animateWithClass(this.dialog, 'show');
this.dispatchEvent(new WaAfterShowEvent());
}
render() {
return html`
<dialog
part="base"
part="dialog"
class=${classMap({
dialog: true,
'dialog--open': this.open,
'dialog--with-header': this.withHeader,
'dialog--with-footer': this.withFooter,
})}
@cancel=${this.handleDialogCancel}
@click=${this.handleDialogClick}
@pointerdown=${this.handleDialogPointerDown}
>
${this.withHeader
? html`
<header part="header">
<header part="header" class="header">
<h2 part="title" class="title" id="title">
<!-- If there's no label, use an invisible character to prevent the header from collapsing -->
<slot name="label"> ${this.label.length > 0 ? this.label : String.fromCharCode(65279)} </slot>
@@ -246,11 +248,11 @@ export default class WaDialog extends WebAwesomeElement {
`
: ''}
<slot part="body" class="body"></slot>
<div part="body" class="body"><slot></slot></div>
${this.withFooter
? html`
<footer part="footer">
<footer part="footer" class="footer">
<slot name="footer"></slot>
</footer>
`
@@ -262,7 +264,7 @@ export default class WaDialog extends WebAwesomeElement {
// Ugly, but it fixes light dismiss in Safari: https://bugs.webkit.org/show_bug.cgi?id=267688
if (!isServer) {
document.body.addEventListener('pointerdown', () => {
document.addEventListener('pointerdown', () => {
/* empty */
});
}

View File

@@ -62,7 +62,6 @@ export default class WaDrawer extends WebAwesomeElement {
private readonly localize = new LocalizeController(this);
private originalTrigger: HTMLElement | null;
private closeWatcher: CloseWatcher | null;
@query('.drawer') drawer: HTMLDialogElement;
@@ -136,16 +135,7 @@ export default class WaDrawer extends WebAwesomeElement {
}
private addOpenListeners() {
if ('CloseWatcher' in window) {
this.closeWatcher?.destroy();
this.closeWatcher = new CloseWatcher();
this.closeWatcher.onclose = () => {
this.requestClose(this.drawer);
};
} else {
this.closeWatcher?.destroy();
document.addEventListener('keydown', this.handleDocumentKeyDown);
}
document.addEventListener('keydown', this.handleDocumentKeyDown);
}
private removeOpenListeners() {

View File

@@ -50,8 +50,6 @@ export default class WaDropdown extends WebAwesomeElement {
@query('#trigger') trigger: HTMLSlotElement;
@query('.panel') panel: HTMLSlotElement;
private closeWatcher: CloseWatcher | null;
/**
* Indicates whether or not the dropdown is open. You can toggle this attribute to show and hide the dropdown, or you
* can use the `show()` and `hide()` methods and this attribute will reflect the dropdown's open state.
@@ -148,7 +146,7 @@ export default class WaDropdown extends WebAwesomeElement {
private handleKeyDown = (event: KeyboardEvent) => {
// Close when escape is pressed inside an open dropdown. 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' && !this.closeWatcher) {
if (this.open && event.key === 'Escape') {
event.stopPropagation();
this.hide();
this.focusOnTrigger();
@@ -344,16 +342,7 @@ export default class WaDropdown extends WebAwesomeElement {
addOpenListeners() {
this.panel.addEventListener('wa-select', this.handlePanelSelect);
if ('CloseWatcher' in window) {
this.closeWatcher?.destroy();
this.closeWatcher = new CloseWatcher();
this.closeWatcher.onclose = () => {
this.hide();
this.focusOnTrigger();
};
} else {
this.panel.addEventListener('keydown', this.handleKeyDown);
}
this.panel.addEventListener('keydown', this.handleKeyDown);
document.addEventListener('keydown', this.handleDocumentKeyDown);
document.addEventListener('mousedown', this.handleDocumentMouseDown);
}
@@ -365,7 +354,6 @@ export default class WaDropdown extends WebAwesomeElement {
}
document.removeEventListener('keydown', this.handleDocumentKeyDown);
document.removeEventListener('mousedown', this.handleDocumentMouseDown);
this.closeWatcher?.destroy();
}
@watch('open', { waitUntilFirstUpdate: true })

View File

@@ -106,6 +106,11 @@ export default class WaOption extends WebAwesomeElement {
}
private handleDefaultSlotChange() {
// Tell the controller to update the label
if (customElements.get('wa-select')) {
this.closest('wa-select')?.selectionChanged();
}
this.updateDefaultLabel();
if (this.isInitialized) {
@@ -123,7 +128,7 @@ export default class WaOption extends WebAwesomeElement {
private handleHover = (event: Event) => {
// We need this because Safari doesn't honor :hover styles while dragging
// Testcase: https://codepen.io/leaverou/pen/VYZOOjy
// Test case: https://codepen.io/leaverou/pen/VYZOOjy
if (event.type === 'mouseenter') {
this.toggleCustomState('hover', true);
} else if (event.type === 'mouseleave') {

View File

@@ -16,7 +16,7 @@
}
.indicator {
width: calc(var(--value, 0) * 1%);
width: var(--percentage);
display: flex;
align-items: center;
justify-content: center;
@@ -26,9 +26,7 @@
overflow: hidden;
line-height: 1;
font-weight: var(--wa-font-weight-semibold);
transition: var(--wa-transition-slow, 200ms) var(--wa-transition-easing, ease);
transition: 1s;
/* transition-property: width, background; */
transition: all var(--wa-transition-slow, 200ms) var(--wa-transition-easing, ease);
user-select: none;
-webkit-user-select: none;
}

View File

@@ -38,8 +38,9 @@ describe('<wa-progress-bar>', () => {
expect(base.getAttribute('aria-valuenow')).to.equal('25');
});
it('uses the value parameter to set the custom property on the indicator', () => {
expect(indicator.style.getPropertyValue('--value')).to.equal('25');
it('uses the value parameter to set the custom property on the indicator', async () => {
await new Promise(requestAnimationFrame);
expect(el.style.getPropertyValue('--percentage')).to.equal('25%');
});
});

View File

@@ -1,6 +1,8 @@
import type { PropertyValues } from 'lit';
import { html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { clamp } from '../../internal/math.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import { LocalizeController } from '../../utilities/localize.js';
import styles from './progress-bar.css';
@@ -33,6 +35,16 @@ export default class WaProgressBar extends WebAwesomeElement {
/** A custom label for assistive devices. */
@property() label = '';
updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has('value')) {
// Wait a cycle before setting it so Safari animates it.
// https://github.com/shoelace-style/webawesome/issues/356
requestAnimationFrame(() => {
this.style.setProperty('--percentage', `${clamp(this.value, 0, 100)}%`);
});
}
}
render() {
return html`
<div
@@ -45,7 +57,7 @@ export default class WaProgressBar extends WebAwesomeElement {
aria-valuemax="100"
aria-valuenow=${this.indeterminate ? '0' : this.value}
>
<div part="indicator" class="indicator" style="--value: ${this.value}">
<div part="indicator" class="indicator">
${!this.indeterminate ? html` <slot part="label" class="label"></slot> ` : ''}
</div>
</div>

View File

@@ -32,7 +32,6 @@
/*
* Checked buttons
*/
:host([checked]) {
--indicator-color: var(--wa-form-control-activated-color);
--indicator-width: var(--wa-border-width-s);
@@ -43,3 +42,41 @@
--border-color: var(--indicator-color);
}
}
/*
* Active buttons
*/
button:active {
--text-color-active: var(--wa-form-control-activated-color);
--border-color-active: var(--wa-form-control-activated-color);
}
/* Horizontal radio pill buttons */
:host([data-wa-radio-horizontal][data-wa-radio-first]:not([data-wa-radio-last])) .wa-pill {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
:host([data-wa-radio-horizontal][data-wa-radio-inner]) .wa-pill {
border-radius: 0;
}
:host([data-wa-radio-horizontal][data-wa-radio-last]:not([data-wa-radio-first])) .wa-pill {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
/* Vertical radio pill buttons */
:host([data-wa-radio-vertical][data-wa-radio-first]:not([data-wa-radio-last])) .wa-pill {
border-end-start-radius: 0;
border-end-end-radius: 0;
}
:host([data-wa-radio-vertical][data-wa-radio-inner]) .wa-pill {
border-radius: 0;
}
:host([data-wa-radio-vertical][data-wa-radio-last]:not([data-wa-radio-first])) .wa-pill {
border-start-start-radius: 0;
border-start-end-radius: 0;
}

View File

@@ -9,7 +9,6 @@ import nativeStyles from '../../styles/native/button.css';
import appearanceStyles from '../../styles/utilities/appearance.css';
import sizeStyles from '../../styles/utilities/size.css';
import variantStyles from '../../styles/utilities/variants.css';
import buttonStyles from '../button/button.css';
import styles from './radio-button.css';
/**
@@ -49,7 +48,7 @@ import styles from './radio-button.css';
*/
@customElement('wa-radio-button')
export default class WaRadioButton extends WebAwesomeFormAssociatedElement {
static shadowStyle = [variantStyles, appearanceStyles, sizeStyles, nativeStyles, buttonStyles, styles];
static shadowStyle = [variantStyles, appearanceStyles, sizeStyles, nativeStyles, styles];
static rectProxy = 'input';
private readonly hasSlotController = new HasSlotController(this, '[default]', 'prefix', 'suffix');

View File

@@ -38,8 +38,6 @@ import styles from './radio-group.css';
* @csspart form-control-input - The input's wrapper.
* @csspart radios - The wrapper than surrounds radio items, styled as a flex container by default.
* @csspart hint - The hint's wrapper.
* @csspart button-group - The button group that wraps radio buttons.
* @csspart button-group__base - The button group's `base` part.
*/
@customElement('wa-radio-group')
export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
@@ -65,7 +63,6 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
@state() private hasButtonGroup = false;
@state() private hasRadioButtons = false;
/**
@@ -150,7 +147,7 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
clickedRadio.checked = true;
const radios = this.getAllRadios();
const hasButtonGroup = radios.some(radio => radio.tagName.toLowerCase() === 'wa-radio-button');
const hasRadioButtons = radios.some(radio => radio.tagName.toLowerCase() === 'wa-radio-button');
for (const radio of radios) {
if (clickedRadio === radio) {
continue;
@@ -158,7 +155,7 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
radio.checked = false;
if (!hasButtonGroup) {
if (!hasRadioButtons) {
radio.setAttribute('tabindex', '-1');
}
}
@@ -183,6 +180,15 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
// Detect the presence of radio buttons
this.hasRadioButtons = radios.some(radio => radio.localName === 'wa-radio-button');
// Add data attributes to support styling
radios.forEach((radio, index) => {
radio.toggleAttribute('data-wa-radio-horizontal', this.orientation !== 'vertical');
radio.toggleAttribute('data-wa-radio-vertical', this.orientation === 'vertical');
radio.toggleAttribute('data-wa-radio-first', index === 0);
radio.toggleAttribute('data-wa-radio-inner', index !== 0 && index !== radios.length - 1);
radio.toggleAttribute('data-wa-radio-last', index === radios.length - 1);
});
await Promise.all(
// Sync the checked state and size
radios.map(async radio => {
@@ -196,10 +202,8 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
}),
);
this.hasButtonGroup = radios.some(radio => radio.tagName.toLowerCase() === 'wa-radio-button');
if (radios.length > 0 && !radios.some(radio => radio.checked)) {
if (this.hasButtonGroup) {
if (this.hasRadioButtons) {
const buttonRadio = radios[0].shadowRoot?.querySelector('button');
if (buttonRadio) {
@@ -210,7 +214,7 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
}
}
if (this.hasButtonGroup) {
if (this.hasRadioButtons) {
const buttonGroup = this.shadowRoot?.querySelector('wa-button-group');
if (buttonGroup) {
@@ -276,12 +280,12 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
index = 0;
}
const hasButtonGroup = radios.some(radio => radio.tagName.toLowerCase() === 'wa-radio-button');
const hasRadioButtons = radios.some(radio => radio.tagName.toLowerCase() === 'wa-radio-button');
this.getAllRadios().forEach(radio => {
radio.checked = false;
if (!hasButtonGroup) {
if (!hasRadioButtons) {
radio.setAttribute('tabindex', '-1');
}
});
@@ -289,7 +293,7 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
this.value = radios[index].value;
radios[index].checked = true;
if (!hasButtonGroup) {
if (!hasRadioButtons) {
radios[index].setAttribute('tabindex', '0');
radios[index].focus();
} else {
@@ -351,7 +355,7 @@ export default class WaRadioGroup extends WebAwesomeFormAssociatedElement {
<slot
part="form-control-input"
class=${classMap({
'wa-button-group': this.hasButtonGroup,
'wa-button-group': this.hasRadioButtons,
'wa-button-group-vertical': this.orientation === 'vertical',
})}
@slotchange=${this.syncRadioElements}

View File

@@ -23,6 +23,10 @@
opacity: 0;
}
[part~='label'] {
display: inline;
}
[part~='hint'] {
grid-column: 2;
margin-block-start: var(--wa-space-3xs);

View File

@@ -22,6 +22,7 @@ import appearanceStyles from '../../styles/utilities/appearance.css';
import sizeStyles from '../../styles/utilities/size.css';
import { LocalizeController } from '../../utilities/localize.js';
import '../icon/icon.js';
import '../option/option.js';
import type WaOption from '../option/option.js';
import '../popup/popup.js';
import type WaPopup from '../popup/popup.js';
@@ -37,6 +38,7 @@ import styles from './select.css';
* @dependency wa-icon
* @dependency wa-popup
* @dependency wa-tag
* @dependency wa-option
*
* @slot - The listbox options. Must be `<wa-option>` elements. You can use `<wa-divider>` to group items visually.
* @slot label - The input's label. Alternatively, you can use the `label` attribute.
@@ -102,7 +104,6 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
private readonly localize = new LocalizeController(this);
private typeToSelectString = '';
private typeToSelectTimeout: number;
private closeWatcher: CloseWatcher | null;
@query('.select') popup: WaPopup;
@query('.combobox') combobox: HTMLSlotElement;
@@ -291,7 +292,6 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
?pill=${this.pill}
size=${this.size}
removable
@wa-remove=${(event: WaRemoveEvent) => this.handleTagRemove(event, option)}
>
${option.label}
</wa-tag>
@@ -321,17 +321,6 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
if (this.getRootNode() !== document) {
this.getRootNode().addEventListener('focusin', this.handleDocumentFocusIn);
}
if ('CloseWatcher' in window) {
this.closeWatcher?.destroy();
this.closeWatcher = new CloseWatcher();
this.closeWatcher.onclose = () => {
if (this.open) {
this.hide();
this.displayInput.focus({ preventScroll: true });
}
};
}
}
private removeOpenListeners() {
@@ -342,8 +331,6 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
if (this.getRootNode() !== document) {
this.getRootNode().removeEventListener('focusin', this.handleDocumentFocusIn);
}
this.closeWatcher?.destroy();
}
private handleFocus() {
@@ -587,10 +574,33 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
this.setSelectedOptions(allOptions.filter(el => value.includes(el.value)));
}
private handleTagRemove(event: WaRemoveEvent, option: WaOption) {
private handleTagRemove(event: WaRemoveEvent, directOption?: WaOption) {
event.stopPropagation();
if (!this.disabled) {
if (this.disabled) return;
// Use the directly provided option if available (from getTag method)
let option = directOption;
// If no direct option was provided, find the option from the event path
if (!option) {
const tagElement = (event.target as Element).closest('wa-tag[part~=tag]');
if (tagElement) {
// Find the index of this tag among all tags
const tagsContainer = this.shadowRoot?.querySelector('[part="tags"]');
if (tagsContainer) {
const allTags = Array.from(tagsContainer.children);
const index = allTags.indexOf(tagElement as HTMLElement);
if (index >= 0 && index < this.selectedOptions.length) {
option = this.selectedOptions[index];
}
}
}
}
if (option) {
this.toggleOptionSelection(option, false);
// Emit after updating
@@ -662,9 +672,9 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
this.selectionChanged();
}
// This method must be called whenever the selection changes. It will update the selected options cache, the current
// value, and the display value
private selectionChanged() {
// @internal This method must be called whenever the selection changes. It will update the selected options cache, the
// current value, and the display value. The option component uses it internally to update labels as they change.
public selectionChanged() {
const options = this.getAllOptions();
// Update selected options cache
@@ -703,14 +713,13 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
this.updateValidity();
});
}
protected get tags() {
return this.selectedOptions.map((option, index) => {
if (index < this.maxOptionsVisible || this.maxOptionsVisible <= 0) {
const tag = this.getTag(option, index);
// Wrap so we can handle the remove
return html`<div @wa-remove=${(e: WaRemoveEvent) => this.handleTagRemove(e, option)}>
${typeof tag === 'string' ? unsafeHTML(tag) : tag}
</div>`;
if (!tag) return null;
return typeof tag === 'string' ? unsafeHTML(tag) : tag;
} else if (index === this.maxOptionsVisible) {
// Hit tag limit
return html`
@@ -726,7 +735,7 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
>
`;
}
return html``;
return null;
});
}
@@ -926,7 +935,9 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement {
/>
<!-- Tags need to wait for first hydration before populating otherwise it will create a hydration mismatch. -->
${this.multiple && this.hasUpdated ? html`<div part="tags" class="tags">${this.tags}</div>` : ''}
${this.multiple && this.hasUpdated
? html`<div part="tags" class="tags" @wa-remove=${this.handleTagRemove}>${this.tags}</div>`
: ''}
<input
class="value-input"

View File

@@ -15,6 +15,14 @@
-webkit-user-select: none;
cursor: pointer;
transition: color var(--wa-transition-fast) var(--wa-transition-easing);
::slotted(wa-icon:first-child) {
margin-inline-end: var(--wa-space-xs);
}
::slotted(wa-icon:last-child) {
margin-inline-start: var(--wa-space-xs);
}
}
:host(:hover:not([disabled])) .tab {

View File

@@ -44,7 +44,6 @@ export default class WaTooltip extends WebAwesomeElement {
static dependencies = { 'wa-popup': WaPopup };
private hoverTimeout: number;
private closeWatcher: CloseWatcher | null;
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
@query('.body') body: HTMLElement;
@@ -127,7 +126,6 @@ export default class WaTooltip extends WebAwesomeElement {
super.disconnectedCallback();
// Cleanup this event in case the tooltip is removed while open
this.closeWatcher?.destroy();
document.removeEventListener('keydown', this.handleDocumentKeyDown);
this.eventController.abort();
@@ -212,15 +210,7 @@ export default class WaTooltip extends WebAwesomeElement {
return;
}
if ('CloseWatcher' in window) {
this.closeWatcher?.destroy();
this.closeWatcher = new CloseWatcher();
this.closeWatcher.onclose = () => {
this.hide();
};
} else {
document.addEventListener('keydown', this.handleDocumentKeyDown, { signal: this.eventController.signal });
}
document.addEventListener('keydown', this.handleDocumentKeyDown, { signal: this.eventController.signal });
this.body.hidden = false;
this.popup.active = true;
@@ -237,7 +227,6 @@ export default class WaTooltip extends WebAwesomeElement {
return;
}
this.closeWatcher?.destroy();
document.removeEventListener('keydown', this.handleDocumentKeyDown);
await animateWithClass(this.popup.popup, 'hide-with-scale');

View File

@@ -81,10 +81,6 @@ input:is([type='button'], [type='reset'], [type='submit']),
* States
*/
&::-moz-focus-inner {
border: 0;
}
&:focus {
outline: none;
}
@@ -103,6 +99,11 @@ input:is([type='button'], [type='reset'], [type='submit']),
pointer-events: none;
}
}
/* Keep it last so Safari doesn't stop parsing this block */
&::-moz-focus-inner {
border: 0;
}
}
/**

View File

@@ -38,6 +38,16 @@ input[type='checkbox']:where(:not(:host *)) {
height: 100%;
width: 100%;
}
&:indeterminate::after {
background-color: currentColor;
content: '';
mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="16" width="14" viewBox="0 0 448 512"><path d="M431 256c0 17.7-14.3 32-32 32H49c-17.7 0-32-14.3-32-32s14.3-32 32-32h350c17.7 0 32 14.3 32 32z"/></svg>')
center no-repeat;
position: absolute;
height: 100%;
width: 100%;
}
}
input[type='checkbox']:where(:not(:host *)),

View File

@@ -2,10 +2,12 @@ details:where(:not(:host *)),
:host {
--icon-color: var(--wa-color-text-quiet);
--spacing: var(--wa-space-m);
--outlined-border-color: var(--wa-color-surface-border);
background-color: var(--wa-color-surface-default);
border: var(--wa-panel-border-width) var(--wa-color-surface-border) var(--wa-panel-border-style);
background-color: var(--background-color, var(--wa-color-surface-default));
border: var(--wa-panel-border-width) var(--border-color, var(--wa-color-surface-border)) var(--wa-panel-border-style);
border-radius: var(--wa-panel-border-radius);
color: var(--text-color, inherit);
padding: var(--spacing);
/* Print styles */
@@ -19,6 +21,12 @@ details:where(:not(:host *)),
}
}
details.wa-plain,
:host(wa-details[appearance='plain']),
:host(wa-details.wa-plain) {
border-radius: 0;
}
details:where(:not(:host *)) summary,
:host summary /* nesting these summary styles in the rule above breaks in Firefox and Safari */ {
display: flex;

View File

@@ -80,8 +80,7 @@
}
.wa-dark,
.wa-invert,
:is(:host-context(.wa-dark)) {
.wa-invert {
/**
* Foundational Colors
*/

View File

@@ -71,8 +71,7 @@
}
.wa-dark,
.wa-invert,
:is(:host-context(.wa-dark)) {
.wa-invert {
color-scheme: dark;
color: var(--wa-color-text-normal);

View File

@@ -78,8 +78,7 @@
}
.wa-dark,
.wa-invert,
:is(:host-context(.wa-dark)) {
.wa-invert {
color-scheme: dark;
color: var(--wa-color-text-normal);

View File

@@ -68,8 +68,7 @@
}
.wa-dark,
.wa-invert,
:is(:host-context(.wa-dark)) {
.wa-invert {
/**
* Foundational Colors
*/

View File

@@ -114,10 +114,8 @@
--wa-color-neutral-on-loud: white;
}
/** need to wrap :host-context() in an :is() selector for unsupported browsers */
.wa-dark,
.wa-invert,
:is(:host-context(.wa-dark)) {
.wa-invert {
color-scheme: dark;
color: var(--wa-color-text-normal);

View File

@@ -85,8 +85,7 @@
}
.wa-dark,
.wa-invert,
:is(:host-context(.wa-dark)) {
.wa-invert {
/**
* Foundational Colors
*/

View File

@@ -83,8 +83,7 @@
}
.wa-dark,
.wa-invert,
:is(:host-context(.wa-dark)) {
.wa-invert {
/**
* Foundational Colors
*/

View File

@@ -75,8 +75,7 @@
}
.wa-dark,
.wa-invert,
:is(:host-context(.wa-dark)) {
.wa-invert {
/**
* Foundational Colors
*/

View File

@@ -74,8 +74,7 @@
}
.wa-dark,
.wa-invert,
:is(:host-context(.wa-dark)) {
.wa-invert {
/**
* Foundational Colors
*/

View File

@@ -84,8 +84,7 @@
}
.wa-dark,
.wa-invert,
:is(:host-context(.wa-dark)) {
.wa-invert {
/**
* Foundational Colors
*/

View File

@@ -1,7 +1,6 @@
:where(:root),
:host,
.wa-theme-tailspin,
:is(:host-context(.wa-theme-tailspin)) {
.wa-theme-tailspin {
h1,
h2,
h3,

View File

@@ -20,6 +20,11 @@
}
/* Horizontal */
.wa-button-group[aria-orientation='horizontal'] {
/* TODO - see https://github.com/shoelace-style/webawesome/issues/374 */
align-items: end;
}
.wa-button-group:not([aria-orientation='vertical']):not(.wa-button-group-vertical) {
> :not(:first-child),
&::slotted(:not(:first-child)) {

View File

@@ -1,20 +1,17 @@
/*
* Utility to minimize FOUCE and show custom elements only after they're registered
*/
:not(:defined),
:state(wa-defined):has(:not(:defined)),
.wa-cloak:has(:not(:defined)) {
animation: 2s step-end wa-fouce-cloak;
}
@keyframes wa-fade-in {
@keyframes wa-fouce-cloak {
from {
opacity: 0;
}
}
:not(:defined),
:state(wa-defined):has(:not(:defined)) {
/* The clamp() ensures that if --wa-fouce-timeout is set to 0s, the whole effect is disabled */
--wa-fouce-animation: clamp(0s, var(--wa-fouce-fade, 200ms), var(--wa-fouce-timeout, 2s)) var(--wa-fouce-timeout, 2s)
wa-fade-in both;
&:where(:not([did-ssr], .wa-fouce-off, .wa-fouce-off *)) {
animation: var(--wa-fouce-animation);
to {
opacity: 1;
}
}

View File

@@ -50,6 +50,12 @@ export async function discover(root: Element | ShadowRoot) {
console.warn(imp.reason); // eslint-disable-line no-console
}
}
// Wait a cycle to allow the first Lit update to run
await new Promise(requestAnimationFrame);
// Dispatch an event when discovery is complete.
document.dispatchEvent(new CustomEvent('wa-discovery-complete'));
}
/**
@@ -69,3 +75,22 @@ function register(tagName: string): Promise<void> {
import(path).then(() => resolve()).catch(() => reject(new Error(`Unable to autoload <${tagName}> from ${path}`)));
});
}
/**
* Acts as a middleware for Turbo's `turbo:before-render` event to ensure components are auto-loaded before showing the
* next page, eliminating page-to-page FOUCE in a Turbo environment.
*/
export function preventTurboFouce(timeout = 2000) {
document.addEventListener('turbo:before-render', async (event: CustomEvent) => {
const newBody = event.detail.newBody;
event.preventDefault();
try {
// Wait until all elements are registered or two seconds, whichever comes first
await Promise.race([discover(newBody), new Promise(resolve => setTimeout(resolve, timeout))]);
} finally {
event.detail.resume();
}
});
}

64
src/utilities/defined.ts Normal file
View File

@@ -0,0 +1,64 @@
interface AllDefinedOptions {
/**
* A callback that accepts a custom element tag name and returns `true` if the custom element should be defined before
* resolving or `false` to skip it. The tag name is always in lowercase.
*/
match: (tagName: string) => boolean;
/**
* To wait for additional custom elements that may not be on the page when the function is called, provide them here.
*/
additionalElements: string | string[];
/**
* The root in which to look for custom elements. Defaults to `document`. By design, shadow roots are not traversed,
* but you can call this function and set `root` to a custom element's shadow root if needed.
*/
root: Document | ShadowRoot;
}
/**
* Waits for custom elements that are currently on the page to be registered before resolving. This is sugar for
* awaiting `customElements.whenDefined()` multiple times. By default, the function waits for all undefined Web Awesome
* elements, but you can pass a custom match function to wait for other custom elements instead.
*
* The function returns with `Promise.all()`, so any loading errors will cause it to reject. Make sure you handle errors
* accordingly using a try/catch block or a `.catch()`.
*
* @example
* // Wait for Web Awesome elements
* await allDefined();
*
* // Wait for all custom elements that start with `foo-` as well as the `<bar-button>` element
* await allDefined({
* match: tagName => tagName.startsWith('foo-'),
* additionalElements: ['bar-button', 'baz-dialog']
* });
*/
export async function allDefined(options?: Partial<AllDefinedOptions>) {
const opts: AllDefinedOptions = {
match: tagName => tagName.startsWith('wa-'),
additionalElements: [],
root: document,
...options,
};
// Ensure additional elements is an array
const additionalElements = Array.isArray(opts.additionalElements)
? opts.additionalElements
: [opts.additionalElements];
// Discover undefined elements in the document
const undefinedElements = [...opts.root.querySelectorAll(':not(:defined)')]
.map(el => el.localName)
.filter((tag, index, arr) => arr.indexOf(tag) === index) // make it unique
.filter(tag => opts.match(tag)); // make sure it matches
const tagsToAwait = [...undefinedElements, ...additionalElements];
// Wait for all to be registered
await Promise.all(tagsToAwait.map(tag => customElements.whenDefined(tag)));
// Wait a cycle for the first update
await new Promise(requestAnimationFrame);
}

View File

@@ -3,3 +3,12 @@ import { startLoader } from './webawesome.js';
export * from './webawesome.js';
startLoader();
// Remove `wa-cloak` when the autoloader finishes OR after two seconds. This prevents the entire screen from flashing
// when unregistered components get added later on.
Promise.race([
new Promise(resolve => document.addEventListener('wa-discovery-complete', resolve)),
new Promise(resolve => setTimeout(resolve, 2000)),
]).then(() => {
document.querySelectorAll('.wa-cloak').forEach(el => el.classList.remove('wa-cloak'));
});

View File

@@ -1,6 +1,7 @@
export { registerIconLibrary, unregisterIconLibrary } from './components/icon/library.js';
export { discover, startLoader, stopLoader } from './utilities/autoloader.js';
export { discover, preventTurboFouce, startLoader, stopLoader } from './utilities/autoloader.js';
export { getBasePath, getKitCode, setBasePath, setKitCode } from './utilities/base-path.js';
export { allDefined } from './utilities/defined.js';
export { registerTranslation } from './utilities/localize.js';
// Utilities

View File

@@ -1,4 +1,3 @@
import { litSsrPlugin } from '@lit-labs/testing/web-test-runner-ssr-plugin.js';
import { esbuildPlugin } from '@web/dev-server-esbuild';
import { playwrightLauncher } from '@web/test-runner-playwright';
import { readFileSync } from 'fs';
@@ -53,7 +52,6 @@ export default {
ts: true,
target: 'es2020',
}),
litSsrPlugin(),
],
browsers: [
playwrightLauncher({ product: 'chromium', concurrency }),
@@ -78,12 +76,10 @@ export default {
${componentImports.map(str => `"${str}"`).join(',\n')}
]
window.SSR_ONLY = ${process.env['SSR_ONLY'] === 'true'}
window.CSR_ONLY = ${process.env['CSR_ONLY'] === 'true'}
</script>
<script type="module">
;(async () => {
await import('@lit-labs/ssr-client/lit-element-hydrate-support.js')
await Promise.allSettled(window.clientComponents.map(str => import(str)));
})()
</script>