- {% set contrast_wcag = '' %}
- {% if color_fg and color_bg %}
- {% set contrast_wcag = color_bg.contrast(color_fg, 'WCAG21') %}
- {% endif %}
+ {% set contrast_wcag = '' %}
+ {% if color_fg and color_bg -%}
+ {% set contrast_wcag = color_bg.contrast(color_fg, 'WCAG21') %}
+ {%- endif %}
+
+
{% if contrast_wcag %}
{{ contrast_wcag | number({maximumSignificantDigits: 2}) }}
{% else %}
diff --git a/docs/_includes/grouped-pages.njk b/docs/_includes/grouped-pages.njk
index 84cf846f5..2273f4c98 100644
--- a/docs/_includes/grouped-pages.njk
+++ b/docs/_includes/grouped-pages.njk
@@ -1,12 +1,18 @@
{# Cards for pages listed by category #}
-{% for category, pages in allPages | groupByTags(categories) -%}
- {{ category | getCategoryTitle(categories) }}
- {%- 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 %}
+
+ {% if pages.meta.url %}{{ pages.meta.title }}
+ {% else %}
+ {{ pages.meta.title }}
+ {% endif %}
+
+ {% endif %}
+ {%- for page in pages -%}
+ {% include "page-card.njk" %}
+ {%- endfor -%}
{%- endfor -%}
diff --git a/docs/_includes/head.njk b/docs/_includes/head.njk
index 695aa836b..3f3a2668e 100644
--- a/docs/_includes/head.njk
+++ b/docs/_includes/head.njk
@@ -1,7 +1,7 @@
-{% if noindex %}
{% endif %}
+{% if noindex or unlisted %}
{% endif %}
{{ title }}
@@ -23,14 +23,17 @@
-
+
+{# Internal components #}
+
{# Web Awesome #}
-
+
{# Preset Theme #}
-{% if forceTheme %}
+{% if noTheme %}
+{% elif forceTheme %}
{% else %}
@@ -47,3 +50,6 @@
+
+{# Used by Web Awesome App to inject other assets into the head. #}
+{% server "head" %}
diff --git a/docs/_includes/import-stylesheet-code.md.njk b/docs/_includes/import-stylesheet-code.md.njk
index c70314b46..d7f538b5b 100644
--- a/docs/_includes/import-stylesheet-code.md.njk
+++ b/docs/_includes/import-stylesheet-code.md.njk
@@ -1,16 +1,16 @@
-
+
In HTML
In CSS
-Simply add the following code to the `` of your page:
+Add the following code to the `` of your page:
```html
```
-Simply add the following code at the top of your CSS file:
+Add the following code at the top of your CSS file:
```css
@import url('{% cdnUrl stylesheet %}');
```
diff --git a/docs/_includes/page-card.njk b/docs/_includes/page-card.njk
index 6afb3fb16..d1111fcf0 100644
--- a/docs/_includes/page-card.njk
+++ b/docs/_includes/page-card.njk
@@ -1,10 +1,13 @@
{%- if not page.data.unlisted -%}
-
+{% if page.url %} {% endif %}
- {% include "svgs/" + (page.data.icon or "thumbnail-placeholder") + ".njk" %}
+ {% include "svgs/" + (page.data.icon or "thumbnail-placeholder") + ".njk" ignore missing %}
{{ page.data.title }}
+ {% if pageSubtitle -%}
+ {{ pageSubtitle }}
+ {%- endif %}
-
+{% if page.url %}{% endif %}
{% endif %}
diff --git a/docs/_includes/preset-theme-selector.njk b/docs/_includes/preset-theme-selector.njk
index 2b57b1389..0a6527d76 100644
--- a/docs/_includes/preset-theme-selector.njk
+++ b/docs/_includes/preset-theme-selector.njk
@@ -2,7 +2,7 @@
{% for theme in collections.theme | sort %}
-
+
{{ theme.data.title }}
{% endfor %}
diff --git a/docs/_includes/search.njk b/docs/_includes/search.njk
index 4007def7c..8448870af 100644
--- a/docs/_includes/search.njk
+++ b/docs/_includes/search.njk
@@ -1,4 +1,4 @@
-
+
{# Header #}
diff --git a/docs/_includes/sidebar-group.njk b/docs/_includes/sidebar-group.njk
index c49ef48da..b723d22ab 100644
--- a/docs/_includes/sidebar-group.njk
+++ b/docs/_includes/sidebar-group.njk
@@ -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) %}
+
- {% if groupUrl | getCollectionItemFromUrl %}
+ {% if groupItem %}
{{ title or (tag | capitalize) }}
@@ -12,10 +15,8 @@
{% endif %}
- {% for page in collections[tag] | sort %}
- {% if not page.data.parent -%}
+ {% for page in children %}
{% include 'sidebar-link.njk' %}
- {%- endif %}
{% endfor %}
diff --git a/docs/_includes/sidebar-link.njk b/docs/_includes/sidebar-link.njk
index dab3e32a0..a7629c1d6 100644
--- a/docs/_includes/sidebar-link.njk
+++ b/docs/_includes/sidebar-link.njk
@@ -1,4 +1,4 @@
-{% if not (isAlpha and page.data.noAlpha) and page.fileSlug != tag and not page.data.unlisted -%}
+{% if page -%}
{{ page.data.title }}
{% if page.data.status == 'experimental' %} {% endif %}
diff --git a/docs/_includes/sidebar.njk b/docs/_includes/sidebar.njk
index bc1e38768..3af2e07f8 100644
--- a/docs/_includes/sidebar.njk
+++ b/docs/_includes/sidebar.njk
@@ -1,7 +1,7 @@
{# Getting started #}
Getting Started
{% for tag, title in {
diff --git a/docs/_includes/svgs/action-panel.njk b/docs/_includes/svgs/action-panel.njk
new file mode 100644
index 000000000..514314c31
--- /dev/null
+++ b/docs/_includes/svgs/action-panel.njk
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/docs/_includes/svgs/call-to-action.njk b/docs/_includes/svgs/call-to-action.njk
new file mode 100644
index 000000000..d577af287
--- /dev/null
+++ b/docs/_includes/svgs/call-to-action.njk
@@ -0,0 +1,3 @@
+
+
+
diff --git a/docs/_includes/svgs/category-list.njk b/docs/_includes/svgs/category-list.njk
new file mode 100644
index 000000000..a40ead60d
--- /dev/null
+++ b/docs/_includes/svgs/category-list.njk
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/_includes/svgs/image-comparer.njk b/docs/_includes/svgs/comparer.njk
similarity index 100%
rename from docs/_includes/svgs/image-comparer.njk
rename to docs/_includes/svgs/comparer.njk
diff --git a/docs/_includes/svgs/palette.njk b/docs/_includes/svgs/palette.njk
index dbe9f4208..380be36ca 100644
--- a/docs/_includes/svgs/palette.njk
+++ b/docs/_includes/svgs/palette.njk
@@ -1,24 +1,20 @@
-{% set paletteId = page.fileSlug %}
-{% set tints = [80, 60, 40, 20] %}
-{% set width = 20 %}
-{% set height = 13 %}
-{% set gap_x = 3 %}
-{% set gap_y = 3 %}
+{% set paletteId = palette.fileSlug or page.fileSlug %}
+{% set suffixes = ['-80', '', '-20'] %}
-
-
+
+
+
+
-{% for hue in hues -%}
- {% set hueIndex = loop.index0 %}
- {% for tint in tints -%}
-
- {%- endfor %}
-{% endfor %}
-
+
+ {% for hue in hues -%}
+ {% set hueIndex = loop.index %}
+ {% for suffix in suffixes -%}
+
+ {%- endfor %}
+ {%- endfor %}
+
+
+
diff --git a/docs/_includes/svgs/preview.njk b/docs/_includes/svgs/preview.njk
new file mode 100644
index 000000000..2ed1a91ed
--- /dev/null
+++ b/docs/_includes/svgs/preview.njk
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/_includes/svgs/scroller.njk b/docs/_includes/svgs/scroller.njk
new file mode 100644
index 000000000..c4f5b7389
--- /dev/null
+++ b/docs/_includes/svgs/scroller.njk
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/_includes/svgs/theme-color.njk b/docs/_includes/svgs/theme-color.njk
new file mode 100644
index 000000000..a5d543ced
--- /dev/null
+++ b/docs/_includes/svgs/theme-color.njk
@@ -0,0 +1,24 @@
+{% set themeId = theme.fileSlug %}
+
+
+
+
+
+
+
+
+
+
A
+
A
+
A
+
A
+ {#
#}
+
+
A
+
A
+
A
+
A
+ {#
#}
+
+
+
diff --git a/docs/_includes/svgs/theme-typography.njk b/docs/_includes/svgs/theme-typography.njk
new file mode 100644
index 000000000..e72813555
--- /dev/null
+++ b/docs/_includes/svgs/theme-typography.njk
@@ -0,0 +1,16 @@
+{% set themeId = theme.fileSlug or page.fileSlug %}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/_includes/svgs/theme.njk b/docs/_includes/svgs/theme.njk
new file mode 100644
index 000000000..613f9ceb4
--- /dev/null
+++ b/docs/_includes/svgs/theme.njk
@@ -0,0 +1,29 @@
+{% set themeId = theme.fileSlug or page.fileSlug %}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/_includes/svgs/tokens/color.njk b/docs/_includes/svgs/tokens/color.njk
index 69591dccb..031aace1a 100644
--- a/docs/_includes/svgs/tokens/color.njk
+++ b/docs/_includes/svgs/tokens/color.njk
@@ -2,7 +2,7 @@
-
+
diff --git a/docs/_includes/theme-showcase.njk b/docs/_includes/theme-showcase.njk
index d0b72966c..64945da36 100644
--- a/docs/_includes/theme-showcase.njk
+++ b/docs/_includes/theme-showcase.njk
@@ -1,14 +1,14 @@
-
+
Your Cart
-
-
+
+
@@ -23,8 +23,8 @@
-
-
+
+
@@ -52,7 +52,7 @@
-
+
“All we have to decide is what to do with the time that is given to us. There are other forces at work in this world, Frodo, besides the will of evil.”
@@ -69,7 +69,7 @@
I forgot my password
-
+
To-Do
@@ -140,5 +140,196 @@
+
+
+
+
Tell Me the Odds
+
+
+
Allow protocol droids to inform you of probabilities, such as the success rate of navigating an asteroid field. We recommend setting this to "Never."
+
+
+
+
+
+
+ Amount
+ $5,610.00
+
+
Paid
+
+
+
+
+
+ Tom Bombadil
+
+
+
+
+
+
+
+ Paid with copper pennies
+
+
+
+
+
+
+
+
+
+
+
Fellowship
+
+
Most Popular
+
+
+ $120
+ per year
+
+
Carry great power (and great responsibility).
+
Get this Plan
+
+
+
What You Get
+
+
+
+ 9 users
+
+
+
+ 1 ring
+
+
+
+ API access to Isengard
+
+
+
+ Priority eagle support
+
+
+
+
+
+
+
+
+
Migs Mayfeld
+ Admin
+
+
Bounty Hunter
+
+
+
+
+
+
+ Email
+
+
+
+ Phone
+
+
+
+
+
+
+
+
+
Decks
+
+
+
You haven’t created any decks yet. Get started by selecting an aspect that matches your play style.
+
+
+
+
diff --git a/docs/_includes/visual-tests/alignment.njk b/docs/_includes/visual-tests/alignment.njk
new file mode 100644
index 000000000..768fa203f
--- /dev/null
+++ b/docs/_includes/visual-tests/alignment.njk
@@ -0,0 +1,77 @@
+```html {.example}
+
+
+
+ Switch
+ Checkbox
+ Radio
+
+
+ Switch
+ Checkbox
+ Radio
+
+
+ Switch
+ Checkbox
+ Radio
+
+
+
+
+
+ Option
+
+
+ Button
+
+
+
+
+
+ Option
+
+
+ Button
+
+
+
+
+
+ Option
+
+
+ Button
+
+
+ Badge
+ Code
+ Keyboard
+ Inserted
+ Deleted
+ Highlighted
+
+
+
+
+
+
+
+
+```
\ No newline at end of file
diff --git a/docs/_includes/visual-tests/appearance.njk b/docs/_includes/visual-tests/appearance.njk
new file mode 100644
index 000000000..9fe1134de
--- /dev/null
+++ b/docs/_includes/visual-tests/appearance.njk
@@ -0,0 +1,799 @@
+
Badge
+
+
+
+
+
Button
+
+
+
+
+
Callout
+
+
+
+
+
Tag
+
+
+
+
+
Form Controls
+
+
\ No newline at end of file
diff --git a/docs/_includes/visual-tests/color.njk b/docs/_includes/visual-tests/color.njk
new file mode 100644
index 000000000..ca06fcbf5
--- /dev/null
+++ b/docs/_includes/visual-tests/color.njk
@@ -0,0 +1,698 @@
+
Badge
+
+
+
+
+
Button
+
+
+
+
+
Callout
+
+
+
+
+
Tag
+
+
\ No newline at end of file
diff --git a/docs/_includes/visual-tests/harmony.njk b/docs/_includes/visual-tests/harmony.njk
new file mode 100644
index 000000000..e6c58112f
--- /dev/null
+++ b/docs/_includes/visual-tests/harmony.njk
@@ -0,0 +1,32 @@
+
Form Controls
+
+```html {.example}
+
+```
+
+
Progress
+
+```html {.example}
+
+
+
+
+
+```
\ No newline at end of file
diff --git a/docs/_includes/visual-tests/native.njk b/docs/_includes/visual-tests/native.njk
new file mode 100644
index 000000000..789d29337
--- /dev/null
+++ b/docs/_includes/visual-tests/native.njk
@@ -0,0 +1,745 @@
+
Button
+
+
+
+
+
Callout
+
+
+
+
+
Checkbox
+
+
+
+
+
Color Picker
+
+
+
+
+
Details
+
+
+
+
+
Input
+
+
+
+
+
Progress Bar
+
+
+
+
+
Radio
+
+
+
+
+
Select
+
+
+
+
+
Slider
+
+
+
+
+
Textarea
+
+
\ No newline at end of file
diff --git a/docs/_includes/visual-tests/size.njk b/docs/_includes/visual-tests/size.njk
new file mode 100644
index 000000000..884cdd0c2
--- /dev/null
+++ b/docs/_includes/visual-tests/size.njk
@@ -0,0 +1,856 @@
+
Button
+
+
+
+
+
Button Group
+
+
+
+
+
Callout
+
+
+
+
+
Card
+
+
+
+
+
Checkbox
+
+
+
+
+
Color Picker
+
+
+
+
+
Dropdown
+
+
+
+
+
Menu
+
+
+
+
+
Input
+
+
+
+
+
Radio
+
+
+
+
+
Radio Button
+
+
+
+
+
Radio Group
+
+
+
+
+
Rating
+
+
+
+
+
Select
+
+
+
+
+
Switch
+
+
+
+
+
Textarea
+
+
\ No newline at end of file
diff --git a/docs/_layouts/blank.njk b/docs/_layouts/blank.njk
index b02206d95..74bf2477c 100644
--- a/docs/_layouts/blank.njk
+++ b/docs/_layouts/blank.njk
@@ -2,6 +2,7 @@
{% include 'head.njk' %}
+ {% block head %}{% endblock %}
diff --git a/docs/_layouts/component.njk b/docs/_layouts/component.njk
index d84329a4f..aa4ca0fa9 100644
--- a/docs/_layouts/component.njk
+++ b/docs/_layouts/component.njk
@@ -255,7 +255,7 @@
{# Importing #}
Importing
- The autoloader is the recommended way to import components. If you prefer to do it manually, use one of the following code snippets.
+ The autoloader is the recommended way to import components. If you prefer to do it manually, use one of the following code snippets.
diff --git a/docs/_layouts/element.njk b/docs/_layouts/element.njk
index 3dc976c0b..a0b5b434b 100644
--- a/docs/_layouts/element.njk
+++ b/docs/_layouts/element.njk
@@ -14,11 +14,19 @@
{% endblock %}
{% block afterContent %}
- {# Slots #}
- {% if css_file %}
- Using these styles
- If you want to use these styles without using the entirety of Web Awesome Native Styles, you can include the following CSS files:
+{% if file %}
+{% markdown %}
+## Opting In to Native {{ title }} Styles
+If you want to use the Native {{ title }} styles **without including the entirety of Web Awesome Native Styles**,
+ you can include the following CSS files from the Web Awesome CDN.
+
+{% set stylesheet = file %}
+{% include 'import-stylesheet-code.md.njk' %}
+
+To use all of Web Awesome Native styles, follow the [instructions on the Native Styles overview page](../).
+
+{% endmarkdown %}
+{% endif %}
- {% endif %}
{% endblock %}
diff --git a/docs/_layouts/overview.njk b/docs/_layouts/overview.njk
index 848ad9fbe..48645d379 100644
--- a/docs/_layouts/overview.njk
+++ b/docs/_layouts/overview.njk
@@ -1,6 +1,5 @@
---
layout: page-outline
-tags: ["overview"]
---
{% set forTag = forTag or (page.url | split('/') | last) %}
{% if description %}
@@ -13,13 +12,15 @@ tags: ["overview"]
-{% set allPages = collections[forTag] %}
+{% set allPages = allPages or collections[forTag] %}
+{% if allPages and allPages.length > 0 %}
{% include "grouped-pages.njk" %}
+{% endif %}
{% if content | trim %}
- {# Temp fix for spacing issue #}
+ {# Temp fix for spacing issue #}
{{ content | safe }}
{% endif %}
diff --git a/docs/_layouts/page.njk b/docs/_layouts/page.njk
index a55a02c21..203527a44 100644
--- a/docs/_layouts/page.njk
+++ b/docs/_layouts/page.njk
@@ -1,4 +1,9 @@
-{% set hasSidebar = true %}
-{% set hasOutline = false %}
+{% if hasSidebar == undefined %}
+ {% set hasSidebar = true %}
+{% endif %}
+
+{% if hasOutline == undefined %}
+ {% set hasOutline = false %}
+{% endif %}
{% extends "../_includes/base.njk" %}
diff --git a/docs/_layouts/palette.njk b/docs/_layouts/palette.njk
index 6903717e4..08b7fffe4 100644
--- a/docs/_layouts/palette.njk
+++ b/docs/_layouts/palette.njk
@@ -1,31 +1,177 @@
{% set hasSidebar = true %}
{% set hasOutline = true %}
-{# {% set forceTheme = page.fileSlug %} #}
-
-{% extends '../_layouts/block.njk' %}
-
{% set paletteId = page.fileSlug %}
-
-{% block afterContent %}
-
-
{% set tints = ["95", "90", "80", "70", "60", "50", "40", "30", "20", "10", "05"] %}
-
+{% extends '../_includes/base.njk' %}
+
+{% block head %}
+
+
+
+{% endblock %}
+
+{% block header %}
+
+
+
+ {% include 'breadcrumbs.njk' %}
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+ Save
+
+
+
+
+
+ .wa-palette-{{ paletteId }}
+ {% include '../_includes/status.njk' %}
+ {% if not isPro %}
+ PRO
+ {% endif %}
+
+ {% if description %}
+
+ {{ description | inlineMarkdown | safe }}
+
+ {% endif %}
+{% endblock %}
+
+{% block afterContent %}
+
+{% set maxChroma = 0 %}
+
+
+
+ This palette has been tweaked.
+
+
+
+
+
+
+
+
+ Reset
+
+
+
+
+ Core tint
{% for tint in tints -%}
{{ tint }}
{%- endfor %}
+{# Initialize to last hue before gray #}
+{%- set hueBefore = hues[hues|length - 2] -%}
{% for hue in hues -%}
-
- {{ hue | capitalize }}
+{% set coreTint = palettes[paletteId][hue].maxChromaTint %}
+{%- set coreColor = palettes[paletteId][hue][coreTint] -%}
+{%- set maxChroma = coreColor.c if coreColor.c > maxChroma else maxChroma -%}
+{% if hue === 'gray' %}
+
+{% else %}
+
+{% endif %}
+
+ {{ hue | capitalize }}
+
+
+
+
+ {{ palettes[paletteId][hue].maxChromaTint }}
+
+
+ `
+
+
{% for tint in tints -%}
-
-
+ {%- set color = palettes[paletteId][hue][tint] -%}
+
+
@@ -34,6 +180,26 @@
{%- endfor %}
+{% set chromaScaleBounds = [
+(0.08 / maxChroma) | number({maximumFractionDigits: 2}),
+(0.3 / maxChroma]) | number({maximumFractionDigits: 2}) -%}
+
+
+
+ Overall colorfulness
+
+
+
+
More muted
+
More vibrant
+
+
Used By
@@ -58,6 +224,7 @@ A difference of `40` ensures a minimum **3:1** contrast ratio, suitable for larg
{% endmarkdown %}
{% set difference = 40 %}
+{% set minContrast = 3 %}
{% include "contrast-table.njk" %}
{% markdown %}
@@ -77,6 +244,7 @@ A difference of `50` ensures a minimum **4.5:1** contrast ratio, suitable for no
{% endmarkdown %}
{% set difference = 50 %}
+{% set minContrast = 4.5 %}
{% include "contrast-table.njk" %}
{% markdown %}
@@ -95,6 +263,7 @@ A difference of `60` ensures a minimum **7:1** contrast ratio, suitable for all
{% endmarkdown %}
{% set difference = 60 %}
+{% set minContrast = 7 %}
{% include "contrast-table.njk" %}
{% markdown %}
@@ -107,13 +276,34 @@ This also goes for a difference of `65`:
{% include "contrast-table.njk" %}
{% markdown %}
-## How to use this palette
+## How to use this palette { #usage }
If you are using a Web Awesome theme that uses this palette, it will already be included.
To use a different palette than a theme default, or to use it in a custom theme, you can import this palette directly from the Web Awesome CDN.
{% set stylesheet = 'styles/color/' + page.fileSlug + '.css' %}
-{% include 'import-stylesheet-code.md.njk' %}
+
+In HTML
+In CSS
+
+
+Add the following code to the `` of your page:
+```html { v-content:html="code.html.highlighted" }
+
+```
+
+
+
+Add the following code at the top of your CSS file:
+```css { v-content:html="code.css.highlighted" }
+@import url('{% cdnUrl stylesheet %}');
+```
+
+
+
{% endmarkdown %}
+ {# end palette app #}
{% endblock %}
+
+
diff --git a/docs/_layouts/patterns.njk b/docs/_layouts/patterns.njk
new file mode 100644
index 000000000..4bd2bd527
--- /dev/null
+++ b/docs/_layouts/patterns.njk
@@ -0,0 +1,5 @@
+{% extends '../_layouts/block.njk' %}
+
+{% block head %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/docs/_layouts/theme.njk b/docs/_layouts/theme.njk
index d890c2406..5efda7907 100644
--- a/docs/_layouts/theme.njk
+++ b/docs/_layouts/theme.njk
@@ -4,99 +4,119 @@
{% extends '../_includes/base.njk' %}
+{% block head %}
+
+
+{% endblock %}
+
{% block header %}
-
-
-
-
-
- Remix
-
-
-
-
- (Theme default)
-
- {% for theme in collections.theme | sort %}
- {% if theme.fileSlug !== page.fileSlug %}
- {{ theme.data.title }}
- {% endif %}
- {% endfor %}
-
-
-
-
- (Theme default)
-
- {% for p in collections.palette | sort %}
- {% if p.fileSlug !== palette %}
- {{ p.data.title }}
- {% endif %}
- {% endfor %}
-
-
-
-
- (Theme default)
-
- {% for theme in collections.theme | sort %}
- {% if theme.fileSlug !== page.fileSlug %}
- {{ theme.data.title }}
- {% endif %}
- {% endfor %}
-
-
-
-
+
+
+
+
+{% if page.fileSlug !== 'custom' %}
+
+
+
+ Remix this theme
+
+ Customize this theme by changing its colors and/or remixing it with design elements from other themes!
+
+
+
+
+
+
+
+ Theme default
+
+
+
+
+
+ theme.brand = value" label="Brand color"
+ :values="hues">
+
+
+
+
+
+
+
+ This theme
+
+
+
+
+
+
+
+
+
+
+
+
+ This theme
+
+
+
+
+
+{% endif %}
+
Color
-
Default Color Palette
-{% set paletteURL = '/docs/palettes/' + palette + '/' %}
-{% set themePage = page %}
-{% set page = paletteURL | getCollectionItemFromUrl %}
-{% include 'page-card.njk' %}
+ {% if page.fileSlug === 'custom' %}
+
+ {% else %}
+ {% set themePage = page %}
+ {% set paletteURL = '/docs/palettes/' + palette + '/' %}
+ {% set page = paletteURL | getCollectionItemFromUrl %}
+ {% set pageSubtitle = "Default color palette" %}
+ {% include 'page-card.njk' %}
+ {% set page = themePage %}
+ {% endif %}
+
+
+ {{ brand | capitalize }}
+ {{ 'Brand color' if page.fileSlug === 'custom' else 'Default brand color' }}
+
-{% set page = themePage %}
-
{% endblock %}
{% block afterContent %}
-{% markdown %}
-## How to use this theme
+
How to use this theme
+
+{% markdown %}
You can import this theme from the Web Awesome CDN.
{% set stylesheet = 'styles/themes/' + page.fileSlug + '.css' %}
-{% include 'import-stylesheet-code.md.njk' %}
-### Remixing { #remixing }
+
+In HTML
+In CSS
+
-If you want to combine the **colors** from this theme with another theme, you can import this CSS file *after* the other theme’s CSS file:
+Add the following code to the `` of your page:
+```html { v-content:html="code.html.highlighted" }
+
+```
+
+
-{% set stylesheet = 'styles/themes/' + page.fileSlug + '/color.css' %}
-{% include 'import-stylesheet-code.md.njk' %}
-
-To use the **typography** from this theme with another theme, you can import this CSS file *after* the other theme’s CSS file:
-
-{% set stylesheet = 'styles/themes/' + page.fileSlug + '/typography.css' %}
-{% include 'import-stylesheet-code.md.njk' %}
-
-
-
- Please note that not all combinations will look good — once you’re mixing and matching, you’re on your own!
-
+Add the following code at the top of your CSS file:
+```css { v-content:html="code.css.highlighted" }
+@import url('{% cdnUrl stylesheet %}');
+```
+
+
## Dark mode
@@ -138,6 +158,7 @@ You can apply the class to the `` element on your page to activate the dar
Web Awesome's themes have both light and dark styles built in.
However, Web Awesome doesn't try to auto-detect the user's light/dark mode preference.
This should be done at the application level.
+
As a best practice, to provide a dark theme in your app, you should:
- Check for [`prefers-color-scheme`](https://stackoverflow.com/a/57795495/567486) and use its value by default
@@ -146,5 +167,19 @@ As a best practice, to provide a dark theme in your app, you should:
Web Awesome avoids using the `prefers-color-scheme` media query because not all apps support dark mode, and it would break things for the ones that don't.
+Assuming the user's preference is in a variable called `colorScheme` (values: `auto`, `light`, `dark`),
+you can use the following JS snippet to apply the `wa-dark` class to the `` element accordingly:
+
+```js
+const systemDark = window.matchMedia('(prefers-color-scheme: dark)');
+const applyDark = function (event = systemDark) {
+ const isDark = colorScheme === 'auto' ? event.matches : colorScheme === 'dark';
+ document.documentElement.classList.toggle('wa-dark', isDark);
+};
+systemDark.addEventListener('change', applyDark);
+applyDark();
+```
+
+
{# end theme app #}
{% endmarkdown %}
{% endblock %}
diff --git a/docs/_layouts/utility.njk b/docs/_layouts/utility.njk
new file mode 100644
index 000000000..032e98277
--- /dev/null
+++ b/docs/_layouts/utility.njk
@@ -0,0 +1,19 @@
+{% extends '../_layouts/block.njk' %}
+
+{% block afterContent %}
+{% if file %}
+{% markdown %}
+## Opting In
+
+If you want to use this utility **only** without [all others](../), you can include the following CSS file from the Web Awesome CDN.
+
+{% set stylesheet = file %}
+{% include 'import-stylesheet-code.md.njk' %}
+
+Want them all?
+Follow the [instructions on the Utilities overview page](../) to get all Web Awesome utilities.
+
+{% endmarkdown %}
+{% endif %}
+
+{% endblock %}
diff --git a/docs/_utils/anchor-headings.js b/docs/_utils/anchor-headings.js
index 88f861194..5fac0cc20 100644
--- a/docs/_utils/anchor-headings.js
+++ b/docs/_utils/anchor-headings.js
@@ -37,7 +37,8 @@ export function anchorHeadingsPlugin(options = {}) {
}
// Look for headings
- container.querySelectorAll(options.headingSelector).forEach(heading => {
+ let selector = `:is(${options.headingSelector}):not([data-no-anchor], [data-no-anchor] *)`;
+ container.querySelectorAll(selector).forEach(heading => {
const hasAnchor = heading.querySelector('a');
const existingId = heading.getAttribute('id');
const clone = parse(heading.outerHTML);
diff --git a/docs/_utils/code-examples.js b/docs/_utils/code-examples.js
index 3b1750197..ed2da68bb 100644
--- a/docs/_utils/code-examples.js
+++ b/docs/_utils/code-examples.js
@@ -102,12 +102,7 @@ const templates = {
export function codeExamplesPlugin(eleventyConfig, options = {}) {
const defaultOptions = {
container: 'body',
- defaultOpen: (code, { outputPathIndex }) => {
- return (
- outputPathIndex === 1 && // is first
- code.textContent.length < 500
- ); // is short
- },
+ defaultOpen: () => false,
};
options = { ...defaultOptions, ...options };
diff --git a/docs/_utils/copy-code.js b/docs/_utils/copy-code.js
index 19d04a958..3b56ae933 100644
--- a/docs/_utils/copy-code.js
+++ b/docs/_utils/copy-code.js
@@ -3,30 +3,39 @@ import { parse } from 'node-html-parser';
/**
* Eleventy plugin to add copy buttons to code blocks.
*/
-export function copyCodePlugin(options = {}) {
+export function copyCodePlugin(eleventyConfig, options = {}) {
options = {
container: 'body',
...options,
};
- return function (eleventyConfig) {
- eleventyConfig.addTransform('copy-code', content => {
- const doc = parse(content, { blockTextElements: { code: true } });
- const container = doc.querySelector(options.container);
+ let codeCount = 0;
+ eleventyConfig.addTransform('copy-code', content => {
+ const doc = parse(content, { blockTextElements: { code: true } });
+ const container = doc.querySelector(options.container);
- if (!container) {
- return content;
+ if (!container) {
+ return content;
+ }
+
+ // Look for code blocks
+ container.querySelectorAll('pre > code').forEach(code => {
+ const pre = code.closest('pre');
+ let preId = pre.getAttribute('id') || `code-block-${++codeCount}`;
+ let codeId = code.getAttribute('id') || `${preId}-inner`;
+
+ if (!code.getAttribute('id')) {
+ code.setAttribute('id', codeId);
+ }
+ if (!pre.getAttribute('id')) {
+ pre.setAttribute('id', preId);
}
- // Look for code blocks
- container.querySelectorAll('pre > code').forEach(code => {
- const pre = code.closest('pre');
-
- // Add a copy button (we set the copy data at runtime to reduce page bloat)
- pre.innerHTML = ` ` + pre.innerHTML;
- });
-
- return doc.toString();
+ // Add a copy button
+ pre.innerHTML += `
+ `;
});
- };
+
+ return doc.toString();
+ });
}
diff --git a/docs/_utils/filters.js b/docs/_utils/filters.js
index 912b5dbb1..c943c17a8 100644
--- a/docs/_utils/filters.js
+++ b/docs/_utils/filters.js
@@ -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;
}
@@ -180,69 +181,185 @@ export function sort(arr, by = { 'data.order': 1, 'data.title': '' }) {
/**
* Group an 11ty collection (or any array of objects with a `data.tags` property) by certain tags.
* @param {object[]} collection
- * @param { Object | (string | Object)[]} [tags] The tags to group by. If not provided/empty, defaults to grouping by all tags.
- * @returns { Object. } An object with keys for each tag, and an array of items for each tag.
+ * @param { Object | 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. } 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' } = 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 = {};
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;
diff --git a/docs/_utils/outline.js b/docs/_utils/outline.js
index 0281ee66b..026a59e00 100644
--- a/docs/_utils/outline.js
+++ b/docs/_utils/outline.js
@@ -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
diff --git a/docs/_utils/remove-data-alpha-elements.js b/docs/_utils/remove-data-alpha-elements.js
deleted file mode 100644
index b93f3d1f5..000000000
--- a/docs/_utils/remove-data-alpha-elements.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { parse } from 'node-html-parser';
-
-/**
- * Eleventy plugin to add remove elements with from the alpha build.
- */
-export function removeDataAlphaElements(options = {}) {
- options = {
- isAlpha: false,
- ...options,
- };
-
- return function (eleventyConfig) {
- eleventyConfig.addTransform('remove-data-alpha-elements', content => {
- const doc = parse(content, { blockTextElements: { code: true } });
-
- if (options.isAlpha) {
- doc.querySelectorAll('[data-alpha="remove"]').forEach(el => el.remove());
- }
-
- return doc.toString();
- });
- };
-}
diff --git a/docs/_utils/search.js b/docs/_utils/search.js
index 1d5ac43a6..90d06c40e 100644
--- a/docs/_utils/search.js
+++ b/docs/_utils/search.js
@@ -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) {
@@ -23,9 +24,23 @@ export function searchPlugin(options = {}) {
};
return function (eleventyConfig) {
- const pagesToIndex = [];
+ const pagesToIndex = new Map();
+
+ eleventyConfig.addPreprocessor('exclude-unlisted-from-search', '*', function (data, content) {
+ if (data.unlisted) {
+ // no-op
+ } else {
+ pagesToIndex.set(data.page.inputPath, {});
+ }
+
+ return content;
+ });
eleventyConfig.addTransform('search', function (content) {
+ if (!pagesToIndex.has(this.page.inputPath)) {
+ return content;
+ }
+
const doc = parse(content, {
blockTextElements: {
script: false,
@@ -41,7 +56,7 @@ export function searchPlugin(options = {}) {
doc.querySelectorAll(selector).forEach(el => el.remove());
});
- pagesToIndex.push({
+ pagesToIndex.set(this.page.inputPath, {
title: collapseWhitespace(options.getTitle(doc)),
description: collapseWhitespace(options.getDescription(doc)),
headings: options.getHeadings(doc).map(collapseWhitespace),
@@ -52,8 +67,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;
@@ -63,7 +79,7 @@ export function searchPlugin(options = {}) {
this.field('h', { boost: 10 });
this.field('c');
- for (const page of pagesToIndex) {
+ for (const [_inputPath, page] of pagesToIndex) {
this.add({ id: index, t: page.title, h: page.headings, c: page.content });
map[index] = { title: page.title, description: page.description, url: page.url };
index++;
diff --git a/docs/assets/components/scoped.js b/docs/assets/components/scoped.js
new file mode 100644
index 000000000..781768ac5
--- /dev/null
+++ b/docs/assets/components/scoped.js
@@ -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:
+ */
+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: \n` +
- `
\n` +
- `
\n` +
- `
\n\n` +
- `${code.textContent}`;
- const css = 'html > body {\n padding: 2rem !important;\n}';
- const js = '';
-
- const form = document.createElement('form');
- form.action = 'https://codepen.io/pen/define';
- form.method = 'POST';
- form.target = '_blank';
-
- const data = {
- title: '',
- description: '',
- tags: ['webawesome'],
- editors: '1000',
- head: '
',
- html_classes: '',
- css_external: '',
- js_external: '',
- js_module: true,
- js_pre_processor: 'none',
- html,
- css,
- js,
- };
-
- const input = document.createElement('input');
- input.type = 'hidden';
- input.name = 'data';
- input.value = JSON.stringify(data);
- form.append(input);
-
- document.documentElement.append(form);
- form.submit();
- form.remove();
- }
-});
diff --git a/docs/assets/scripts/copy-code.js b/docs/assets/scripts/copy-code.js
deleted file mode 100644
index ff8f82a6e..000000000
--- a/docs/assets/scripts/copy-code.js
+++ /dev/null
@@ -1,15 +0,0 @@
-function setCopyValue() {
- document.querySelectorAll('.copy-button').forEach(copyButton => {
- const pre = copyButton.closest('pre');
- const code = pre?.querySelector('code');
-
- if (code) {
- copyButton.value = code.textContent;
- }
- });
-}
-
-// Set data for all copy buttons when the page loads
-setCopyValue();
-
-document.addEventListener('turbo:load', setCopyValue);
diff --git a/docs/assets/scripts/filter.js b/docs/assets/scripts/filter.js
index 11d9d8190..d0b16de9e 100644
--- a/docs/assets/scripts/filter.js
+++ b/docs/assets/scripts/filter.js
@@ -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) {
}
}
-document.documentElement.addEventListener('wa-input', e => {
+const debouncedUpdateResults = debounce(updateResults, 300);
+
+document.documentElement.addEventListener('input', e => {
if (e.target?.matches('#block-filter wa-input')) {
- updateResults(e.target);
+ debouncedUpdateResults(e.target);
}
});
diff --git a/docs/assets/scripts/hydration-errors.js b/docs/assets/scripts/hydration-errors.js
index 67c6d0d1a..30b076666 100644
--- a/docs/assets/scripts/hydration-errors.js
+++ b/docs/assets/scripts/hydration-errors.js
@@ -51,7 +51,7 @@
Show Hydration Mismatch
-
+
Server
diff --git a/docs/assets/scripts/my.js b/docs/assets/scripts/my.js
new file mode 100644
index 000000000..25793113d
--- /dev/null
+++ b/docs/assets/scripts/my.js
@@ -0,0 +1,172 @@
+const my = (globalThis.my = new EventTarget());
+export default my;
+
+class PersistedArray extends Array {
+ constructor(key) {
+ super();
+ this.key = key;
+
+ if (this.key) {
+ this.fromLocalStorage();
+ }
+
+ // Items were updated in another tab
+ addEventListener('storage', event => {
+ if (event.key === this.key || !event.key) {
+ this.fromLocalStorage();
+ }
+ });
+ }
+
+ /**
+ * Update data from local storage
+ */
+ fromLocalStorage() {
+ // First, empty the array
+ this.splice(0, this.length);
+
+ // Then, fill it with the data from local storage
+ let saved = localStorage[this.key] ? JSON.parse(localStorage[this.key]) : null;
+
+ if (saved) {
+ this.push(...saved);
+ }
+ }
+
+ /**
+ * Write data to local storage
+ */
+ toLocalStorage() {
+ if (this.length > 0) {
+ localStorage[this.key] = JSON.stringify(this);
+ } else {
+ delete localStorage[this.key];
+ }
+ }
+}
+
+class SavedEntities extends EventTarget {
+ constructor({ key, type, url }) {
+ super();
+ this.key = key;
+ this.type = type;
+ this.url = url ?? type + 's';
+ this.saved = new PersistedArray(key);
+
+ let all = this;
+ this.entityPrototype = {
+ type: this.type,
+ baseUrl: this.baseUrl,
+
+ get url() {
+ return all.getURL(this);
+ },
+
+ get parentUrl() {
+ return all.getParentURL(this);
+ },
+
+ delete() {
+ all.delete(this);
+ },
+ };
+ }
+
+ getUid() {
+ if (this.saved.length === 0) {
+ return 1;
+ }
+
+ let uids = new Set(this.saved.map(p => p.uid));
+
+ // Find first available number
+ for (let i = 1; i <= this.saved.length + 1; i++) {
+ if (!uids.has(i)) {
+ return i;
+ }
+ }
+ }
+
+ get baseUrl() {
+ return `/docs/${this.url}/`;
+ }
+
+ getURL(entity) {
+ return this.getParentURL(entity) + entity.search;
+ }
+
+ getParentURL(entity) {
+ return this.baseUrl + entity.id + '/';
+ }
+
+ getObject(entity) {
+ let ret = Object.create(this.entityPrototype, Object.getOwnPropertyDescriptors(entity));
+ // debugger;
+ return ret;
+ }
+
+ /**
+ * Save an entity, either by updating its existing entry or creating a new one
+ * @param {object} entity
+ */
+ save(entity) {
+ if (!entity.uid) {
+ // First time saving
+ entity.uid = this.getUid();
+ }
+
+ let savedPalettes = this.saved;
+ let existingIndex = entity.uid ? this.saved.findIndex(p => p.uid === entity.uid) : -1;
+ let newIndex = existingIndex > -1 ? existingIndex : savedPalettes.length;
+
+ this.saved.splice(newIndex, 1, entity);
+
+ this.saved.toLocalStorage();
+
+ this.dispatchEvent(new CustomEvent('save', { detail: this.getObject(entity) }));
+
+ return entity;
+ }
+
+ delete(entity) {
+ let count = this.saved.length;
+
+ if (count === 0 || !entity?.uid) {
+ // No stored entities or this entity has not been saved
+ return;
+ }
+
+ // TODO improve UX of this
+ if (!confirm(`Are you sure you want to delete ${this.type} “${entity.title}”?`)) {
+ return;
+ }
+
+ for (let index; (index = this.saved.findIndex(p => p.uid === entity.uid)) > -1; ) {
+ this.saved.splice(index, 1);
+ }
+
+ if (this.saved.length === count) {
+ // Nothing was removed
+ return;
+ }
+
+ this.saved.toLocalStorage();
+
+ this.dispatchEvent(new CustomEvent('delete', { detail: this.getObject(entity) }));
+ }
+
+ dispatchEvent(event) {
+ super.dispatchEvent(event);
+ my.dispatchEvent(event);
+ }
+}
+
+my.palettes = new SavedEntities({
+ key: 'savedPalettes',
+ type: 'palette',
+});
+
+my.themes = new SavedEntities({
+ key: 'savedThemes',
+ type: 'theme',
+});
diff --git a/docs/assets/scripts/permalink.js b/docs/assets/scripts/permalink.js
new file mode 100644
index 000000000..07283493b
--- /dev/null
+++ b/docs/assets/scripts/permalink.js
@@ -0,0 +1,157 @@
+import { deepEach, deepGet, deepSet } from './util/deep.js';
+
+export default class Permalink extends URLSearchParams {
+ /** Params changed since last URL I/O */
+ changed = false;
+
+ constructor(params) {
+ super(location.search);
+ this.params = params;
+ }
+
+ toJSON() {
+ return Object.fromEntries(this.entries());
+ }
+
+ /**
+ * Set multiple values from an object. Nested values will be joined with a hyphen.
+ * @param {object} values - The object containing the values to set.
+ * @param {object} defaults - The object containing the default values.
+ *
+ */
+ setAll(values, defaults) {
+ deepEach(values, (value, key, parent, path) => {
+ let fullPath = [...path, key];
+ let param = fullPath.join('-');
+ let defaultValue = deepGet(defaults, fullPath);
+
+ if (typeof value === 'object') {
+ // We'll handle this when we descend into it
+ return;
+ }
+
+ if (!value || value === defaultValue) {
+ // Remove the param from the URL
+ this.delete(param);
+ return;
+ }
+
+ this.set(param, value);
+ });
+ }
+
+ getAll(...args) {
+ if (args.length > 0) {
+ return super.getAll(...args);
+ }
+
+ // Get all values as a nested object
+ // Assumes that hyphens always mean nesting
+ let obj = {};
+
+ for (let [key, value] of this.entries()) {
+ let path = key.split('-');
+ deepSet(obj, path, value);
+ }
+
+ return obj;
+ }
+
+ delete(key, value) {
+ let hadValue = this.has(key);
+ super.delete(key, value);
+
+ if (hadValue) {
+ this.changed = true;
+ }
+ }
+
+ set(key, value, defaultValue) {
+ if (equals(value, defaultValue) || equals(value, '')) {
+ value = null;
+ }
+
+ value ??= null; // undefined -> null
+
+ let oldValue = Array.isArray(value) ? this.getAll(key) : this.get(key);
+ let changed = !equals(value, oldValue);
+
+ if (!changed) {
+ // Nothing to do here
+ return;
+ }
+
+ if (Array.isArray(value)) {
+ super.delete(key);
+ value = value.slice();
+
+ for (let v of value) {
+ if (v || v === 0) {
+ if (typeof v === 'object') {
+ super.append(key, JSON.stringify(v));
+ } else {
+ super.append(key, v);
+ }
+ }
+ }
+ } else if (value === null) {
+ super.delete(key);
+ } else {
+ super.set(key, value);
+ }
+
+ this.sort();
+ this.changed ||= changed;
+ }
+
+ /**
+ * Update page URL if it has changed since last time
+ */
+ updateLocation() {
+ if (this.changed) {
+ // If there’s already a search, replace it.
+ // We don’t want to clog the user’s history while they iterate
+ let search = this.toString();
+ let historyAction = location.search && search ? 'replaceState' : 'pushState';
+ history[historyAction](null, '', `?${search}`);
+ this.changed = false;
+ }
+ }
+}
+
+function equals(value, oldValue) {
+ if (Array.isArray(value) || Array.isArray(oldValue)) {
+ value = toArray(value);
+ oldValue = toArray(oldValue);
+
+ if (value.length !== oldValue.length) {
+ return false;
+ }
+
+ return value.every((v, i) => equals(v, oldValue[i]));
+ }
+
+ // (value ?? oldValue ?? true) returns true if they're both empty (null or undefined)
+ [value, oldValue] = [value, oldValue].map(v => (!v && v !== false && v !== 0 ? null : v));
+ return value === oldValue || String(value) === String(oldValue);
+}
+
+/**
+ * Convert a value to an array. `undefined` and `null` values are converted to an empty array.
+ * @param {*} value - The value to convert.
+ * @returns {any[]} The converted array.
+ */
+function toArray(value) {
+ value ??= [];
+
+ if (Array.isArray(value)) {
+ return value;
+ }
+
+ // Don't convert "foo" into ["f", "o", "o"]
+ if (typeof value !== 'string' && typeof value[Symbol.iterator] === 'function') {
+ return Array.from(value);
+ }
+
+ return [value];
+}
diff --git a/docs/assets/scripts/prism-downloaded.js b/docs/assets/scripts/prism-downloaded.js
new file mode 100644
index 000000000..be9653dd6
--- /dev/null
+++ b/docs/assets/scripts/prism-downloaded.js
@@ -0,0 +1,8 @@
+/* PrismJS 1.29.0
+https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript&plugins=custom-class */
+var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(e){var n=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,t=0,r={},a={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof i?new i(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&").replace(/=g.reach);A+=w.value.length,w=w.next){var E=w.value;if(n.length>e.length)return;if(!(E instanceof i)){var P,L=1;if(y){if(!(P=l(b,A,e,m))||P.index>=e.length)break;var S=P.index,O=P.index+P[0].length,j=A;for(j+=w.value.length;S>=j;)j+=(w=w.next).value.length;if(A=j-=w.value.length,w.value instanceof i)continue;for(var C=w;C!==n.tail&&(j
g.reach&&(g.reach=W);var z=w.prev;if(_&&(z=u(n,z,_),A+=_.length),c(n,z,L),w=u(n,z,new i(f,p?a.tokenize(N,p):N,k,N)),M&&u(n,w,M),L>1){var I={cause:f+","+d,reach:W};o(e,n,t,w.prev,A,I),g&&I.reach>g.reach&&(g.reach=I.reach)}}}}}}function s(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function u(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function c(e,n,t){for(var r=n.next,a=0;a"+i.content+""+i.tag+">"},!e.document)return e.addEventListener?(a.disableWorkerMessageHandler||e.addEventListener("message",(function(n){var t=JSON.parse(n.data),r=t.language,i=t.code,l=t.immediateClose;e.postMessage(a.highlight(i,a.languages[r],r)),l&&e.close()}),!1),a):a;var g=a.util.currentScript();function f(){a.manual||a.highlightAll()}if(g&&(a.filename=g.src,g.hasAttribute("data-manual")&&(a.manual=!0)),!a.manual){var h=document.readyState;"loading"===h||"interactive"===h&&g&&g.defer?document.addEventListener("DOMContentLoaded",f):window.requestAnimationFrame?window.requestAnimationFrame(f):window.setTimeout(f,16)}return a}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism);
+Prism.languages.markup={comment:{pattern://,greedy:!0},prolog:{pattern:/<\?[\s\S]+?\?>/,greedy:!0},doctype:{pattern:/"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,greedy:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^$|[[\]]/,"doctype-tag":/^DOCTYPE/i,name:/[^\s<>'"]+/}},cdata:{pattern://i,greedy:!0},tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{punctuation:[{pattern:/^=/,alias:"attr-equals"},{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:"named-entity"},/?[\da-f]{1,8};/i]},Prism.languages.markup.tag.inside["attr-value"].inside.entity=Prism.languages.markup.entity,Prism.languages.markup.doctype.inside["internal-subset"].inside=Prism.languages.markup,Prism.hooks.add("wrap",(function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&/,"&"))})),Object.defineProperty(Prism.languages.markup.tag,"addInlined",{value:function(a,e){var s={};s["language-"+e]={pattern:/(^$)/i,lookbehind:!0,inside:Prism.languages[e]},s.cdata=/^$/i;var t={"included-cdata":{pattern://i,inside:s}};t["language-"+e]={pattern:/[\s\S]+/,inside:Prism.languages[e]};var n={};n[a]={pattern:RegExp("(<__[^>]*>)(?:))*\\]\\]>|(?!)".replace(/__/g,(function(){return a})),"i"),lookbehind:!0,greedy:!0,inside:t},Prism.languages.insertBefore("markup","cdata",n)}}),Object.defineProperty(Prism.languages.markup.tag,"addAttribute",{value:function(a,e){Prism.languages.markup.tag.inside["special-attr"].push({pattern:RegExp("(^|[\"'\\s])(?:"+a+")\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))","i"),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[e,"language-"+e],inside:Prism.languages[e]},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}}}})}}),Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup,Prism.languages.xml=Prism.languages.extend("markup",{}),Prism.languages.ssml=Prism.languages.xml,Prism.languages.atom=Prism.languages.xml,Prism.languages.rss=Prism.languages.xml;
+!function(s){var e=/(?:"(?:\\(?:\r\n|[\s\S])|[^"\\\r\n])*"|'(?:\\(?:\r\n|[\s\S])|[^'\\\r\n])*')/;s.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:RegExp("@[\\w-](?:[^;{\\s\"']|\\s+(?!\\s)|"+e.source+")*?(?:;|(?=\\s*\\{))"),inside:{rule:/^@[\w-]+/,"selector-function-argument":{pattern:/(\bselector\s*\(\s*(?![\s)]))(?:[^()\s]|\s+(?![\s)])|\((?:[^()]|\([^()]*\))*\))+(?=\s*\))/,lookbehind:!0,alias:"selector"},keyword:{pattern:/(^|[^\w-])(?:and|not|only|or)(?![\w-])/,lookbehind:!0}}},url:{pattern:RegExp("\\burl\\((?:"+e.source+"|(?:[^\\\\\r\n()\"']|\\\\[^])*)\\)","i"),greedy:!0,inside:{function:/^url/i,punctuation:/^\(|\)$/,string:{pattern:RegExp("^"+e.source+"$"),alias:"url"}}},selector:{pattern:RegExp("(^|[{}\\s])[^{}\\s](?:[^{};\"'\\s]|\\s+(?![\\s{])|"+e.source+")*(?=\\s*\\{)"),lookbehind:!0},string:{pattern:e,greedy:!0},property:{pattern:/(^|[^-\w\xA0-\uFFFF])(?!\s)[-_a-z\xA0-\uFFFF](?:(?!\s)[-\w\xA0-\uFFFF])*(?=\s*:)/i,lookbehind:!0},important:/!important\b/i,function:{pattern:/(^|[^-a-z0-9])[-a-z0-9]+(?=\()/i,lookbehind:!0},punctuation:/[(){};:,]/},s.languages.css.atrule.inside.rest=s.languages.css;var t=s.languages.markup;t&&(t.tag.addInlined("style","css"),t.tag.addAttribute("style","css"))}(Prism);
+Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|extends|implements|instanceof|interface|new|trait)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:break|catch|continue|do|else|finally|for|function|if|in|instanceof|new|null|return|throw|try|while)\b/,boolean:/\b(?:false|true)\b/,function:/\b\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/};
+Prism.languages.javascript=Prism.languages.extend("clike",{"class-name":[Prism.languages.clike["class-name"],{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$A-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\.(?:constructor|prototype))/,lookbehind:!0}],keyword:[{pattern:/((?:^|\})\s*)catch\b/,lookbehind:!0},{pattern:/(^|[^.]|\.\.\.\s*)\b(?:as|assert(?=\s*\{)|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\s*(?:\{|$))|for|from(?=\s*(?:['"]|$))|function|(?:get|set)(?=\s*(?:[#\[$\w\xA0-\uFFFF]|$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/,lookbehind:!0}],function:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/,number:{pattern:RegExp("(^|[^\\w$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w$])"),lookbehind:!0},operator:/--|\+\+|\*\*=?|=>|&&=?|\|\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\.{3}|\?\?=?|\?\.?|[~:]/}),Prism.languages.javascript["class-name"][0].pattern=/(\b(?:class|extends|implements|instanceof|interface|new)\s+)[\w.\\]+/,Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:RegExp("((?:^|[^$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)/(?:(?:\\[(?:[^\\]\\\\\r\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\r\n])+/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\r\n]|\\\\.|\\[(?:[^[\\]\\\\\r\n]|\\\\.|\\[(?:[^[\\]\\\\\r\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\r\n])+/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|/\\*(?:[^*]|\\*(?!/))*\\*/)*(?:$|[\r\n,.;:})\\]]|//))"),lookbehind:!0,greedy:!0,inside:{"regex-source":{pattern:/^(\/)[\s\S]+(?=\/[a-z]*$)/,lookbehind:!0,alias:"language-regex",inside:Prism.languages.regex},"regex-delimiter":/^\/|\/$/,"regex-flags":/^[a-z]+$/}},"function-variable":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)\s*=>))/,alias:"function"},parameter:[{pattern:/(function(?:\s+(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)?\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\))/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$a-z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*=>)/i,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*=>)/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*)\(\s*|\]\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*\{)/,lookbehind:!0,inside:Prism.languages.javascript}],constant:/\b[A-Z](?:[A-Z_]|\dx?)*\b/}),Prism.languages.insertBefore("javascript","string",{hashbang:{pattern:/^#!.*/,greedy:!0,alias:"comment"},"template-string":{pattern:/`(?:\\[\s\S]|\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}|(?!\$\{)[^\\`])*`/,greedy:!0,inside:{"template-punctuation":{pattern:/^`|`$/,alias:"string"},interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}/,lookbehind:!0,inside:{"interpolation-punctuation":{pattern:/^\$\{|\}$/,alias:"punctuation"},rest:Prism.languages.javascript}},string:/[\s\S]+/}},"string-property":{pattern:/((?:^|[,{])[ \t]*)(["'])(?:\\(?:\r\n|[\s\S])|(?!\2)[^\\\r\n])*\2(?=\s*:)/m,lookbehind:!0,greedy:!0,alias:"property"}}),Prism.languages.insertBefore("javascript","operator",{"literal-property":{pattern:/((?:^|[,{])[ \t]*)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*:)/m,lookbehind:!0,alias:"property"}}),Prism.languages.markup&&(Prism.languages.markup.tag.addInlined("script","javascript"),Prism.languages.markup.tag.addAttribute("on(?:abort|blur|change|click|composition(?:end|start|update)|dblclick|error|focus(?:in|out)?|key(?:down|up)|load|mouse(?:down|enter|leave|move|out|over|up)|reset|resize|scroll|select|slotchange|submit|unload|wheel)","javascript")),Prism.languages.js=Prism.languages.javascript;
+!function(){if("undefined"!=typeof Prism){var n,s,a="";Prism.plugins.customClass={add:function(s){n=s},map:function(n){s="function"==typeof n?n:function(s){return n[s]||s}},prefix:function(n){a=n||""},apply:t},Prism.hooks.add("wrap",(function(e){if(n){var u=n({content:e.content,type:e.type,language:e.language});Array.isArray(u)?e.classes.push.apply(e.classes,u):u&&e.classes.push(u)}(s||a)&&(e.classes=e.classes.map((function(n){return t(n,e.language)})))}))}function t(n,t){return a+(s?s(n,t):n)}}();
diff --git a/docs/assets/scripts/prism.js b/docs/assets/scripts/prism.js
new file mode 100644
index 000000000..215b8092d
--- /dev/null
+++ b/docs/assets/scripts/prism.js
@@ -0,0 +1,8 @@
+globalThis.Prism = globalThis.Prism || {};
+globalThis.Prism.manual = true;
+
+await import('./prism-downloaded.js');
+
+Prism.plugins.customClass.prefix('code-');
+
+export default Prism;
diff --git a/docs/assets/scripts/sidebar-tweaks.js b/docs/assets/scripts/sidebar-tweaks.js
new file mode 100644
index 000000000..168004c53
--- /dev/null
+++ b/docs/assets/scripts/sidebar-tweaks.js
@@ -0,0 +1,120 @@
+import my from '/assets/scripts/my.js';
+
+const sidebar = {
+ addChild(a, parentA) {
+ let parentLi = parentA.closest('li');
+ let ul = parentLi.querySelector(':scope > ul');
+ ul ??= parentLi.appendChild(document.createElement('ul'));
+ let li = document.createElement('li');
+ li.append(a);
+ ul.appendChild(li);
+
+ // If we are on the same page, update the current link
+ let url = location.href.replace(/#.+$/, '');
+ if (url.startsWith(a.href)) {
+ // Remove existing current
+ for (let current of document.querySelectorAll('#sidebar a.current')) {
+ current.classList.remove('current');
+ }
+
+ a.classList.add('current');
+ }
+
+ return a;
+ },
+
+ removeLink(a) {
+ if (!a || !a.isConnected) {
+ // Link doesn't exist or is already removed
+ return;
+ }
+
+ let li = a?.closest('li');
+ let ul = li?.closest('ul');
+ let parentA = ul?.closest('li')?.querySelector(':scope > a');
+
+ li?.remove();
+ if (ul?.children.length === 0) {
+ ul.remove();
+ }
+
+ if (a.classList.contains('current')) {
+ // If the deleted palette was the current one, the current one is now the parent
+ parentA.classList.add('current');
+ }
+ },
+
+ findEntity(entity) {
+ return document.querySelector(`#sidebar a[href^="${entity.baseUrl}"][data-uid="${entity.uid}"]`);
+ },
+
+ renderEntity(entity) {
+ let { url, parentUrl } = entity;
+
+ // Find parent
+ let parentA = document.querySelector(`#sidebar a[href="${parentUrl}"]`);
+ let parentLi = parentA?.closest('li');
+
+ if (!parentLi) {
+ throw new Error(`Cannot find parent url ${parentUrl}`);
+ }
+
+ // Find existing
+ let a = this.findEntity(entity);
+ let alreadyExisted = !!a;
+
+ a ??= document.createElement('a');
+
+ a.textContent = entity.title;
+ a.href = url;
+
+ if (!alreadyExisted) {
+ a.dataset.uid = entity.uid;
+
+ a = sidebar.addChild(a, parentA);
+
+ // This is mainly to port Pro badges
+ let badges = Array.from(parentLi.querySelectorAll(':scope > wa-badge'), badge => badge.cloneNode(true));
+
+ let append = [...badges];
+
+ if (entity.delete) {
+ let deleteButton = Object.assign(document.createElement('wa-icon-button'), {
+ name: 'trash',
+ label: 'Delete',
+ className: 'delete',
+ });
+ deleteButton.addEventListener('click', () => entity.delete());
+ append.push(deleteButton);
+ }
+
+ if (append.length > 0) {
+ a.closest('li').append(' ', ...append);
+ }
+ }
+ },
+
+ render() {
+ for (let type in my) {
+ let controller = my[type];
+
+ if (!controller.saved) {
+ continue;
+ }
+
+ for (let entity of controller.saved) {
+ let object = controller.getObject(entity);
+ this.renderEntity(object);
+ }
+ }
+ },
+};
+
+globalThis.sidebar = sidebar;
+
+// Update sidebar when my saved stuff changes
+my.addEventListener('delete', e => sidebar.removeLink(sidebar.findEntity(e.detail)));
+my.addEventListener('save', e => sidebar.renderEntity(e.detail));
+
+sidebar.render();
+window.addEventListener('turbo:render', () => sidebar.render());
diff --git a/docs/assets/scripts/theme-picker.js b/docs/assets/scripts/theme-picker.js
index b1f31f76b..df547510a 100644
--- a/docs/assets/scripts/theme-picker.js
+++ b/docs/assets/scripts/theme-picker.js
@@ -1,14 +1,5 @@
-// Helper for view transitions
-export function domChange(fn, { behavior = 'smooth' } = {}) {
- const canUseViewTransitions =
- document.startViewTransition && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
-
- if (canUseViewTransitions && behavior === 'smooth') {
- document.startViewTransition(fn);
- } else {
- fn(true);
- }
-}
+import { domChange } from './util/dom-change.js';
+export { domChange };
export function nextFrame() {
return new Promise(resolve => requestAnimationFrame(resolve));
@@ -28,7 +19,7 @@ export class ThemeAspect {
});
// Listen for selections
- document.addEventListener('wa-change', event => {
+ document.addEventListener('change', event => {
const picker = event.target.closest(this.picker);
if (picker) {
this.set(picker.value);
@@ -100,6 +91,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 } }));
});
},
});
@@ -114,6 +106,6 @@ document.addEventListener('keydown', event => {
!event.composedPath().some(el => ['input', 'textarea'].includes(el?.tagName?.toLowerCase()))
) {
event.preventDefault();
- colorScheme.set(theming.colorScheme.resolvedValue === 'dark' ? 'light' : 'dark');
+ colorScheme.set(colorScheme.get() === 'dark' ? 'light' : 'dark');
}
});
diff --git a/docs/assets/scripts/turbo.js b/docs/assets/scripts/turbo.js
index fb1d0ea0f..e00e470c3 100644
--- a/docs/assets/scripts/turbo.js
+++ b/docs/assets/scripts/turbo.js
@@ -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();
diff --git a/docs/assets/scripts/tweak.js b/docs/assets/scripts/tweak.js
new file mode 100644
index 000000000..47f1f5702
--- /dev/null
+++ b/docs/assets/scripts/tweak.js
@@ -0,0 +1,6 @@
+/**
+ * Get import code for remixed themes and tweaked palettes.
+ */
+export { cdnUrl, hueRanges, hues, selectors, tints, urls } from '../data/index.js';
+export { default as Permalink } from './permalink.js';
+export { getThemeCode } from './tweak/code.js';
diff --git a/docs/assets/scripts/tweak/code.js b/docs/assets/scripts/tweak/code.js
new file mode 100644
index 000000000..b2f206223
--- /dev/null
+++ b/docs/assets/scripts/tweak/code.js
@@ -0,0 +1,91 @@
+/**
+ * Get import code for remixed themes and tweaked palettes.
+ */
+import { selectors, themeConfig } from '../../data/theming.js';
+import { deepEach, deepGet } from '/assets/scripts/util/deep.js';
+
+export function cssImport(url, options = {}) {
+ let { language = 'html', cdnUrl = '/dist/', attributes } = options;
+ url = cdnUrl + url;
+
+ if (language === 'css') {
+ return `@import url('${url}');`;
+ } else {
+ attributes = attributes ? ` ${attributes}` : '';
+ return ` `;
+ }
+}
+
+export function cssLiteral(value, options = {}) {
+ let { language = 'html' } = options;
+
+ if (language === 'css') {
+ return value;
+ } else {
+ return ``;
+ }
+}
+
+/**
+ * Get code for a theme, including tweaks
+ * @param {*} theme
+ * @param {*} options
+ * @returns
+ */
+export function getThemeCode(theme, options = {}) {
+ let urls = [];
+ let declarations = [];
+ let id = options.id ?? theme.base ?? 'default';
+
+ deepEach(themeConfig, (config, aspect, obj, path) => {
+ if (!config?.default) {
+ // We're not in a config object
+ return;
+ }
+
+ let value = deepGet(theme, [...path, aspect]);
+
+ if (!value) {
+ return;
+ }
+
+ if (config.url) {
+ // This is implemented by pulling in different CSS files
+ urls.push(config.url(value));
+ } else {
+ if (config.cssProperty) {
+ declarations.push(`${config.cssProperty}: ${value};`);
+ }
+ }
+ });
+
+ let ret = urls.map(url => cssImport(url, options)).join('\n');
+
+ if (declarations.length > 0) {
+ let cssCode = cssRule(selectors.theme(id), declarations, options);
+
+ let faKitAttribute = ` data-fa-kit-code="${theme.icon.kit}"`;
+ if (theme.icon.kit) {
+ options.attributes ??= '';
+ options.attributes += faKitAttribute;
+ cssCode =
+ `/* Note: To use Font Awesome Pro icons,\n set ${faKitAttribute} on the (or any other) element */\n\n` +
+ cssCode;
+ }
+
+ cssCode = cssLiteral(cssCode, options);
+
+ if (ret) {
+ ret += '\n\n' + cssCode;
+ }
+ }
+
+ return ret;
+}
+
+export function cssRule(selector, declarations, { indent = ' ' } = {}) {
+ selector = Array.isArray(selector) ? selector.flat().join(',\n') : selector;
+ declarations = Array.isArray(declarations) ? declarations.flat() : declarations;
+ declarations = declarations.map(declaration => indent + declaration.trim()).join('\n');
+ return `${selector} {\n${declarations.trimEnd()}\n}`;
+}
diff --git a/docs/assets/scripts/tweak/util.js b/docs/assets/scripts/tweak/util.js
new file mode 100644
index 000000000..12ee8dd55
--- /dev/null
+++ b/docs/assets/scripts/tweak/util.js
@@ -0,0 +1,36 @@
+export function normalizeAngles(angles) {
+ // First, normalize
+ angles = angles.map(h => ((h % 360) + 360) % 360);
+
+ // Remove top and bottom 25% and find average
+ let averageHue =
+ angles
+ .toSorted((a, b) => a - b)
+ .slice(angles.length / 4, -angles.length / 4)
+ .reduce((a, b) => a + b, 0) / angles.length;
+
+ for (let i = 0; i < angles.length; i++) {
+ let h = angles[i];
+ let prevHue = angles[i - 1];
+ let delta = h - prevHue;
+
+ if (Math.abs(delta) > 180) {
+ let equivalent = [h + 360, h - 360];
+ // Offset hue to minimize difference in the direction that brings it closer to the average
+ let delta = h - averageHue;
+
+ if (Math.abs(equivalent[0] - prevHue) <= Math.abs(equivalent[1] - prevHue)) {
+ angles[i] = equivalent[0];
+ } else {
+ angles[i] = equivalent[1];
+ }
+ }
+ }
+
+ return angles;
+}
+
+export function subtractAngles(θ1, θ2) {
+ let [a, b] = normalizeAngles([θ1, θ2]);
+ return a - b;
+}
diff --git a/docs/assets/scripts/util/array.js b/docs/assets/scripts/util/array.js
new file mode 100644
index 000000000..6eff57b6d
--- /dev/null
+++ b/docs/assets/scripts/util/array.js
@@ -0,0 +1,17 @@
+/**
+ * Picks a random element from an array.
+ * @param {any[]} arr
+ */
+export function sample(arr) {
+ if (!Array.isArray(arr)) {
+ return arr;
+ }
+
+ if (arr.length < 2) {
+ return arr[0];
+ }
+
+ let index = Math.floor(Math.random() * arr.length);
+
+ return arr[index];
+}
diff --git a/docs/assets/scripts/util/deep.js b/docs/assets/scripts/util/deep.js
new file mode 100644
index 000000000..4bfcfd5d7
--- /dev/null
+++ b/docs/assets/scripts/util/deep.js
@@ -0,0 +1,180 @@
+/**
+ * @typedef { string | number | Symbol } Property
+ * @typedef { (value: any, key: Property, parent: object, path: Property[]) => any } EachCallback
+ */
+
+export function isPlainObject(obj) {
+ return isObject(obj, 'Object');
+}
+
+export function isObject(obj, type) {
+ if (!obj || typeof obj !== 'object') {
+ return false;
+ }
+
+ let proto = Object.getPrototypeOf(obj);
+ return proto.constructor?.name === type;
+}
+
+export function deepMerge(target, source, options = {}) {
+ let {
+ emptyValues = [undefined],
+ containers = ['Object', 'EventTarget'],
+ isContainer = value => containers.some(type => isObject(value, type)),
+ } = options;
+
+ if (isContainer(target) && isContainer(source)) {
+ for (let key in source) {
+ if (key in target && isContainer(target[key]) && isContainer(source[key])) {
+ target[key] = deepMerge(target[key], source[key], options);
+ } else if (!emptyValues.includes(source[key])) {
+ target[key] = source[key];
+ }
+ }
+
+ return target;
+ }
+
+ return target ?? source;
+}
+
+/**
+ * Iterate over a deep array, recursively for plain objects
+ * @param { any } obj The object to iterate over. Can be an array or a plain object, or even a primitive value.
+ * @param { EachCallback } callback. value is === parent[key]
+ * @param { object } [parentObj] The parent object of the current value Mainly used internally to facilitate recursion.
+ * @param { Property } [key] The key of the current value. Mainly used internally to facilitate recursion.
+ * @param { Property[] } [path] Any existing path (not including the key). Mainly used internally to facilitate recursion.
+ */
+export function deepEach(obj, callback, parentObj, key, path = []) {
+ if (key !== undefined) {
+ let ret = callback(obj, key, parentObj, path);
+
+ if (ret !== undefined) {
+ if (ret === false) {
+ // Do not descend further
+ return;
+ }
+
+ // Overwrite value
+ parentObj[key] = ret;
+ obj = ret;
+ }
+ }
+
+ let newPath = key !== undefined ? [...path, key] : path;
+
+ if (Array.isArray(obj)) {
+ for (let i = 0; i < obj.length; i++) {
+ deepEach(obj[i], callback, obj, i, newPath);
+ }
+ } else if (isPlainObject(obj)) {
+ for (let key in obj) {
+ deepEach(obj[key], callback, obj, key, newPath);
+ }
+ }
+}
+
+/**
+ * Get a value from a deeply nested object
+ * @param {*} obj
+ * @param {PropertyPath} path
+ * @returns
+ */
+export function deepGet(obj, path) {
+ if (path.length === 0) {
+ return obj;
+ }
+
+ let ret = obj;
+
+ for (let key of path) {
+ if (ret === undefined) {
+ return undefined;
+ }
+
+ ret = ret[key];
+ }
+
+ return ret;
+}
+
+/**
+ * Set a value in a deep object, creating object literals as needed
+ * @param { * } obj
+ * @param { Property[] } path
+ * @param { any } value
+ */
+export function deepSet(obj, path, value) {
+ if (path.length === 0) {
+ return;
+ }
+
+ let key = path.pop();
+
+ let ret = path.reduce((acc, property) => {
+ if (acc[property] === undefined) {
+ acc[property] = {};
+ }
+
+ return acc[property];
+ }, obj);
+
+ ret[key] = value;
+}
+
+export function deepClone(obj) {
+ if (!obj) {
+ return obj;
+ }
+
+ let ret = obj;
+
+ if (Array.isArray(obj)) {
+ ret = obj.map(item => deepClone(item));
+ } else if (isPlainObject(obj)) {
+ ret = { ...obj };
+
+ for (let key in obj) {
+ ret[key] = deepClone(obj[key]);
+ }
+ }
+
+ return ret;
+}
+
+/**
+ * Like Object.entries, but for deeply nested objects.
+ * For shallow objects the output is the same as Object.entries.
+ * @param {*} obj
+ * @param { object } options
+ * @param { EachCallback } each - If this returns false, the entry is not added to the result and the recursion is stopped.
+ * @param { EachCallback } filter - If this returns false, the entry is not added to the result.
+ * @param { EachCallback } descend - If this returns false, recursion is stopped.
+ * @returns {any[][]}
+ */
+export function deepEntries(obj, options = {}) {
+ let { each, filter, descend } = options;
+ let entries = [];
+
+ deepEach(obj, (value, key, parent, path) => {
+ let ret = each?.(value, key, parent, path);
+
+ if (ret !== false) {
+ let included = filter?.(value, key, parent, path) ?? true;
+
+ if (included) {
+ entries.push([...path, key, value]);
+ }
+
+ let descendRet = descend?.(value, key, parent, path);
+ if (descendRet === false) {
+ return false; // Stop recursion
+ }
+ }
+
+ return ret;
+ });
+
+ return entries;
+}
diff --git a/docs/assets/scripts/util/dom-change.js b/docs/assets/scripts/util/dom-change.js
new file mode 100644
index 000000000..f01a3d13c
--- /dev/null
+++ b/docs/assets/scripts/util/dom-change.js
@@ -0,0 +1,39 @@
+let initialPageLoadComplete = document.readyState === 'complete';
+
+if (!initialPageLoadComplete) {
+ window.addEventListener('load', () => {
+ initialPageLoadComplete = true;
+ });
+}
+
+/**
+ * Helper for performing a DOM change using a view transition, wherever supported and reduced motion is not desired.
+ * @param {function} fn - Function to perform the DOM change. If async, must resolve when the change is complete.
+ * @param {object} [options] - Options for the transition
+ * @param {'smooth' | 'instant'} [options.behavior] - Transition behavior. Defaults to 'smooth'. 'instant' will skip the transition.
+ * @param {boolean} [options.ignoreInitialLoad] - If true, will skip the transition on initial page load. Defaults to true.
+ */
+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') {
+ 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(false);
+ return null;
+ }
+}
+
+export default domChange;
diff --git a/docs/assets/scripts/util/string.js b/docs/assets/scripts/util/string.js
new file mode 100644
index 000000000..214940f3f
--- /dev/null
+++ b/docs/assets/scripts/util/string.js
@@ -0,0 +1,24 @@
+/**
+ * Make the first letter of a string uppercase
+ * @param {*} str
+ * @returns
+ */
+export function capitalize(str) {
+ str += '';
+ return str[0].toUpperCase() + str.slice(1);
+}
+
+/**
+ * Convert a readable string to a slug.
+ * @param {*} str - Input string. If argument is not a string, it will be stringified.
+ * @returns {string} - The slugified string
+ */
+export function slugify(str) {
+ return (str + '')
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '') // Convert accented letters to ASCII
+ .replace(/[^\w\s-]/g, '') // Remove remaining non-ASCII characters
+ .trim()
+ .replace(/\s+/g, '-') // Convert whitespace to hyphens
+ .toLowerCase();
+}
diff --git a/docs/assets/styles/code-examples.css b/docs/assets/styles/code-examples.css
deleted file mode 100644
index 497ac17d5..000000000
--- a/docs/assets/styles/code-examples.css
+++ /dev/null
@@ -1,87 +0,0 @@
-.code-example {
- border: var(--wa-border-style) var(--wa-panel-border-width) var(--wa-color-neutral-border-quiet);
- border-radius: var(--wa-border-radius-l);
- color: var(--wa-color-text-normal);
- margin-block-end: var(--wa-flow-spacing);
-}
-
-.code-example-preview {
- padding: 2rem;
- border-bottom: var(--wa-border-style) var(--wa-panel-border-width) var(--wa-color-neutral-border-quiet);
-
- > :first-child {
- margin-block-start: 0;
- }
-
- > :last-child {
- margin-block-end: 0;
- }
-}
-
-.code-example-source {
- border-bottom: var(--wa-border-style) var(--wa-panel-border-width) var(--wa-color-neutral-border-quiet);
-}
-
-.code-example:not(.open) .code-example-source {
- display: none;
-}
-
-.code-example.open .code-example-toggle wa-icon {
- rotate: 180deg;
-}
-
-.code-example-source pre {
- position: relative;
- border-radius: 0;
- margin: 0;
- white-space: normal;
-}
-
-.code-example-source:not(:has(+ .code-example-buttons)) {
- border-bottom: none;
-
- pre {
- border-bottom-right-radius: var(--wa-border-radius-l);
- border-bottom-left-radius: var(--wa-border-radius-l);
- }
-}
-
-.code-example-buttons {
- display: flex;
- align-items: stretch;
-
- button {
- all: unset;
- flex: 1 0 auto;
- font-size: 0.875rem;
- color: var(--wa-color-text-quiet);
- border-left: var(--wa-border-style) var(--wa-panel-border-width) var(--wa-color-neutral-border-quiet);
- text-align: center;
- padding: 0.5rem;
- cursor: pointer;
-
- &:first-of-type {
- border-left: none;
- border-bottom-left-radius: var(--wa-border-radius-l);
- }
-
- &:last-of-type {
- border-bottom-right-radius: var(--wa-border-radius-l);
- }
-
- &:focus-visible {
- outline: var(--wa-focus-ring);
- }
- }
-
- .code-example-pen {
- flex: 0 0 100px;
- white-space: nowrap;
- }
-
- wa-icon {
- width: 1em;
- height: 1em;
- vertical-align: -2px;
- }
-}
diff --git a/docs/assets/styles/copy-code.css b/docs/assets/styles/copy-code.css
index d9fe94e36..f65887b64 100644
--- a/docs/assets/styles/copy-code.css
+++ b/docs/assets/styles/copy-code.css
@@ -27,3 +27,19 @@ wa-copy-button.copy-button {
opacity: 1;
}
}
+
+.block-link-icon {
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline-end: calc(100% + var(--wa-space-s));
+
+ transition: var(--wa-transition-slow);
+
+ &:not(:hover, :focus) {
+ opacity: 50%;
+ }
+
+ :not(:hover, :focus-within) > & {
+ opacity: 0;
+ }
+}
diff --git a/docs/assets/styles/docs.css b/docs/assets/styles/docs.css
index b98b5bc8f..8bd5b1391 100644
--- a/docs/assets/styles/docs.css
+++ b/docs/assets/styles/docs.css
@@ -1,9 +1,9 @@
-@import 'code-examples.css';
@import 'code-highlighter.css';
@import 'copy-code.css';
@import 'outline.css';
@import 'search.css';
@import 'cera_typeface.css';
+@import 'theme-icons.css';
:root {
--wa-brand-orange: #f36944;
@@ -156,7 +156,7 @@ wa-page > header {
}
/* Pro badges */
-wa-badge.pro::part(base) {
+wa-badge.pro {
background-color: var(--wa-brand-orange);
border-color: var(--wa-brand-orange);
}
@@ -188,6 +188,29 @@ wa-badge.pro::part(base) {
}
}
}
+
+ wa-icon-button.delete {
+ vertical-align: -0.2em;
+ margin-inline-start: var(--wa-space-xs);
+
+ &:not(li:hover > *, :focus) {
+ opacity: 0;
+ }
+ }
+}
+
+wa-icon-button.delete {
+ &:hover {
+ color: var(--wa-color-danger-on-quiet);
+ }
+
+ &::part(base):hover {
+ background: var(--wa-color-danger-fill-quiet);
+ }
+
+ &:not(:hover, :focus) {
+ opacity: 0.5;
+ }
}
#sidebar-close-button {
@@ -232,16 +255,23 @@ wa-page > main {
margin-inline: auto;
}
-h1.title wa-badge {
- vertical-align: middle;
-
- &::part(base) {
+h1.title {
+ wa-badge {
+ vertical-align: middle;
font-size: 1.5rem;
}
}
.block-info {
+ display: flex;
+ gap: var(--wa-space-xs);
+ flex-wrap: wrap;
+ align-items: center;
margin-block-end: var(--wa-flow-spacing);
+
+ code {
+ line-height: var(--wa-line-height-condensed);
+ }
}
/* Current link */
@@ -331,48 +361,47 @@ 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;
}
wa-card {
- box-shadow: none;
--spacing: var(--wa-space-m);
- inline-size: 100%;
-
- &:hover {
- --border-color: var(--wa-color-brand-border-loud);
- border-color: var(--border-color);
- box-shadow: 0 0 0 var(--wa-border-width-s) var(--border-color);
-
- .page-name {
- color: var(--wa-color-brand-on-quiet);
- }
- }
[slot='header'] {
display: flex;
}
&::part(header) {
- background-color: var(--wa-color-neutral-fill-quiet);
- border-bottom: none;
+ background-color: var(--header-background, var(--wa-color-neutral-fill-quiet));
display: flex;
align-items: center;
justify-content: center;
min-block-size: calc(6rem + var(--spacing));
}
}
+}
- .page-name {
- font-size: var(--wa-font-size-s);
- font-weight: var(--wa-font-weight-action);
- }
+wa-card .page-name {
+ font-size: var(--wa-font-size-s);
+ font-weight: var(--wa-font-weight-action);
}
.index-category {
@@ -381,6 +410,146 @@ wa-page > main:has(> .index-grid) {
margin-block-start: var(--wa-space-2xl);
}
+/* Interactive cards */
+wa-card[role='button'][tabindex='0'],
+button,
+a[href],
+wa-option,
+wa-radio,
+wa-checkbox {
+ /* Disabled state */
+ &:is(:disabled, [disabled], [aria-disabled='true']) {
+ &:is(wa-card, :has(> wa-card)) {
+ opacity: 60%;
+ cursor: not-allowed;
+ }
+ }
+
+ &:where(:not(:disabled, [disabled], [aria-disabled='true'])) {
+ &:has(> wa-card) {
+ /* Parents only (not interactive ) */
+ margin: calc(var(--wa-border-width-m) + 1px);
+ padding: 0;
+
+ /* Hover state */
+ &:hover,
+ &:state(hover),
+ &:state(current) {
+ /* Do not change the parent background as a hover effect (we style the card instead) */
+ background: transparent !important;
+ }
+
+ &::part(control),
+ &:is(wa-option)::part(checked-icon) {
+ --background-color-checked: var(--wa-color-brand-fill-loud);
+ --checked-icon-scale: 0.5;
+ --offset: var(--wa-space-2xs);
+
+ position: absolute;
+ inset: calc(var(--offset) + var(--wa-border-width-m));
+ inset-block-end: auto;
+ inset-inline-start: auto;
+ z-index: 1;
+ margin: 0;
+ background: var(--wa-color-brand-fill-loud);
+ color: var(--wa-color-brand-on-loud);
+ }
+
+ &::part(checked-icon) {
+ color: var(--wa-color-brand-on-loud);
+ }
+
+ &:is(wa-option)::part(checked-icon) {
+ inset-block-start: calc(var(--wa-space-smaller) - 0.5em);
+ inset-inline-end: calc(var(--wa-space-smaller) - 0.5em);
+ width: 1em;
+ height: 1em;
+ line-height: 1em;
+ padding: 0.4em;
+ border-radius: var(--wa-border-radius-circle);
+ text-align: center;
+ font-size: var(--wa-font-size-xs);
+ }
+ }
+
+ /* Hover state */
+ &:hover,
+ &:state(hover),
+ &:state(current) {
+ &:is(wa-card),
+ > wa-card {
+ --border-color: var(--wa-color-brand-border-loud);
+ border-color: var(--border-color);
+ box-shadow: 0 0 0 var(--wa-border-width-s) var(--border-color);
+ }
+ }
+
+ &:is(wa-card, :has(> wa-card)) {
+ /* Interactive card parent */
+ position: relative;
+ cursor: pointer;
+
+ /* Unselected state */
+ &:where(:not(:state(checked), :state(selected), [aria-checked='true'], [aria-selected='true'])) {
+ &::part(checked-icon),
+ &::part(control) {
+ display: none;
+ }
+ }
+ }
+
+ &:is(wa-card),
+ > wa-card {
+ /* The card itself */
+ box-shadow: none;
+ }
+ }
+}
+
+/* Selected cards */
+:state(selected),
+:state(checked),
+[aria-checked='true'],
+[aria-selected='true'] {
+ &:is(wa-card, :has(> wa-card)) {
+ background: transparent;
+ }
+
+ &:is(wa-card),
+ > wa-card {
+ --border-color: var(--wa-color-brand-border-loud);
+ box-shadow: 0 0 0 var(--wa-border-width-m) var(--border-color);
+
+ &::part(body) {
+ background: var(--wa-color-brand-fill-quiet);
+ }
+ }
+}
+
+wa-select:has(> wa-option > wa-card) {
+ &::part(listbox) {
+ --column-width: 1fr;
+ --columns: 1;
+ --gap: var(--wa-space-smaller);
+
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(var(--column-width), 1fr));
+ width: calc(var(--columns) * var(--column-width) + (var(--columns) - 1) * var(--gap) + 2 * var(--wa-space));
+ max-width: var(--auto-size-available-width, 90vw);
+ gap: var(--gap);
+ padding: var(--wa-space-smaller) var(--wa-space);
+ }
+
+ > wa-option > wa-card {
+ --spacing: var(--wa-space-s);
+ }
+}
+
+wa-radio:has(> wa-card) {
+ grid-template-columns: 1fr;
+ width: auto;
+}
+
/* Swatches */
.swatch {
position: relative;
@@ -400,9 +569,18 @@ wa-page > main:has(> .index-grid) {
&.color {
border-color: transparent;
+ transition: background var(--wa-transition-slow);
+ background: linear-gradient(var(--color-2, transparent) 0% 100%) no-repeat border-box var(--color,);
+ background-position: var(--color-2-position, bottom);
+ background-size: var(--color-2-width, 100%) var(--color-2-height, 50%);
+
+ &.contrast-fail {
+ outline: 1px dashed var(--wa-color-red);
+ outline-offset: calc(-1 * var(--wa-space-2xs));
+ }
}
- wa-copy-button {
+ > wa-copy-button {
position: absolute;
top: 0;
left: 0;
@@ -437,6 +615,7 @@ table.colors {
padding-block: 0;
}
}
+
tbody {
tr {
border: none;
@@ -456,6 +635,59 @@ table.colors {
padding-block: var(--wa-space-s);
}
}
+
+ .core-column {
+ padding-inline-end: var(--wa-space-xl);
+ }
+}
+
+.value-up,
+.value-down {
+ position: relative;
+
+ &::after {
+ content: ' ' var(--icon);
+ position: absolute;
+ margin-inline-start: 3em;
+ scale: 1 0.6;
+ color: color-mix(in oklch, oklch(from var(--icon-color) none c h) 0%, oklch(from currentColor l none none));
+ font-size: 90%;
+ }
+}
+
+.value-down {
+ --icon: '▼';
+ --icon-color: var(--wa-color-danger-fill-quiet);
+
+ &::after {
+ margin-block-end: -0.2em;
+ }
+}
+
+.value-up {
+ --icon: '▲';
+ --icon-color: var(--wa-color-success-fill-quiet);
+}
+
+.icon-modifier {
+ position: relative;
+ display: inline-flex;
+
+ .modifier {
+ position: absolute;
+ bottom: -0.1em;
+ right: -0.3em;
+ font-size: 60%;
+
+ &::part(svg) {
+ stroke: var(--background-color, var(--wa-color-surface-default));
+ stroke-width: 100px;
+ paint-order: stroke;
+ overflow: visible;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ }
+ }
}
/* Layout Examples */
@@ -494,13 +726,6 @@ table.colors {
margin-block-end: var(--wa-flow-spacing);
}
-/** mobile */
-@media screen and (max-width: 768px) {
- wa-page .only-desktop {
- display: none;
- }
-}
-
/** desktop */
@media screen and not (max-width: 768px) {
/* Navigation sidebar */
@@ -538,23 +763,4 @@ table.colors {
height: 65vh;
max-height: 21lh;
}
-
- #mix_and_match {
- strong {
- display: flex;
- align-items: center;
- gap: var(--wa-space-2xs);
- margin-top: 1.2em;
- }
-
- wa-select::part(label) {
- margin-block-end: 0;
- }
-
- wa-select[value='']::part(display-input),
- wa-option[value=''] {
- font-style: italic;
- color: var(--wa-color-text-quiet);
- }
- }
}
diff --git a/docs/assets/styles/search.css b/docs/assets/styles/search.css
index 66798001c..8e0f37771 100644
--- a/docs/assets/styles/search.css
+++ b/docs/assets/styles/search.css
@@ -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,26 +24,26 @@
@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 */
-header {
+#site-search-container header {
flex: 0 0 auto;
- align-items: middle;
+ align-items: center;
/* Fixes an iOS Safari 16.4 bug that draws the parent element's border radius incorrectly when showing/hiding results */
border-radius: var(--wa-border-radius-l);
}
diff --git a/docs/assets/styles/theme-icons.css b/docs/assets/styles/theme-icons.css
new file mode 100644
index 000000000..f98c7f053
--- /dev/null
+++ b/docs/assets/styles/theme-icons.css
@@ -0,0 +1,189 @@
+wa-card:has(
+ > .theme-icon-host,
+ > [slot='header'] > .theme-icon-host,
+ > .fonts-icon-host,
+ > [slot='header'] > .fonts-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,
+.fonts-icon-host,
+.palette-icon-host {
+ flex: 1;
+ border-radius: inherit;
+
+ &[slot='header'],
+ [slot='header']:has(&) {
+ flex: 1;
+ border-radius: inherit;
+ }
+}
+
+.theme-icon:not(.theme-color-icon),
+.palette-icon,
+.icons-icon {
+ min-height: 5.5rem;
+}
+
+.palette-icon {
+ display: grid;
+ grid-template-columns: repeat(var(--hues, 9), 1fr);
+ gap: var(--wa-space-3xs);
+ min-width: 20ch;
+ align-content: center;
+
+ .swatch {
+ height: 0.7em;
+ background: var(--color);
+ border-radius: var(--wa-border-radius-s);
+
+ &[data-suffix=''] {
+ height: 1.1em;
+ }
+ }
+}
+
+.theme-icon,
+.fonts-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: flex;
+ gap: var(--wa-space-xs);
+ min-width: 15ch;
+ background: var(--wa-color-surface-lowered);
+
+ & + & {
+ border-start-start-radius: 0;
+ border-start-end-radius: 0;
+ }
+
+ div {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ border-radius: var(--wa-border-radius-m);
+ background-color: var(--background-color);
+ border: var(--wa-border-width-s) var(--wa-border-style) var(--border-color);
+ padding: var(--wa-space-2xs) var(--wa-space-xs);
+ color: var(--text-color);
+ font-weight: var(--wa-font-weight-semibold);
+ }
+}
+
+.theme-icon.theme-overall-icon,
+.fonts-icon {
+ display: flex;
+ flex-flow: column;
+ gap: var(--wa-space-2xs);
+ justify-content: center;
+ width: 100%;
+ min-height: 6.75rem;
+ 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;
+ }
+ }
+ }
+}
+
+.fonts-icon {
+ font-family: var(--wa-font-family-body);
+ padding-block: var(--wa-space-s);
+ overflow: hidden;
+ position: relative;
+
+ & h2,
+ & p {
+ white-space: nowrap;
+ }
+
+ &::after {
+ content: '';
+ position: absolute;
+ right: 0;
+ width: 50%;
+ height: 100%;
+ background-image: linear-gradient(to left, var(--wa-color-surface-lowered), 20%, transparent);
+ }
+}
+
+.icons-icon {
+ display: grid;
+ grid-template-columns: repeat(var(--columns, 5), auto);
+ gap: var(--wa-space-xs);
+ place-items: center;
+ place-content: center;
+
+ & wa-icon {
+ font-size: 1.25em;
+ }
+}
+
+.page-card {
+ wa-badge {
+ margin-inline: var(--wa-space-3xs);
+ }
+}
+
+:is(.theme-card, .icons-card)::part(header) {
+ background: var(--wa-color-surface-lowered);
+}
+
+.icons-card::part(header) {
+ color: var(--wa-color-neutral-on-quiet);
+}
diff --git a/docs/assets/styles/ui.css b/docs/assets/styles/ui.css
new file mode 100644
index 000000000..9ee09916c
--- /dev/null
+++ b/docs/assets/styles/ui.css
@@ -0,0 +1,145 @@
+/* App UI, for themer, palette tweaking etc */
+
+:root {
+ --fa-sliders-simple: '\f1de';
+}
+
+.title {
+ wa-icon-button {
+ font-size: var(--wa-font-size-l);
+ color: var(--wa-color-text-quiet);
+
+ &:not(:hover, :focus) {
+ opacity: 0.5;
+ }
+ }
+}
+
+.popup {
+ background: var(--wa-color-surface-default);
+ border: 1px solid var(--wa-color-surface-border);
+ padding: var(--wa-space-xl);
+ border-radius: var(--wa-border-radius-m);
+ max-height: 90dvh;
+ overflow: auto;
+
+ code {
+ white-space: nowrap;
+ }
+}
+
+.color-select {
+ &.default::part(display-input) {
+ opacity: 0.6;
+ font-style: italic;
+ }
+
+ > small {
+ margin-inline-start: var(--wa-space-xs);
+ padding-block: 0 var(--wa-space-xs);
+ }
+
+ &::part(combobox)::before,
+ wa-option::before {
+ content: '';
+ display: inline-block;
+ width: 1.2em;
+ aspect-ratio: 1;
+ margin-inline-end: var(--wa-space-xs);
+ flex: none;
+ border-radius: var(--wa-border-radius-m);
+ background: var(--color);
+ border: 1px solid var(--wa-color-surface-default);
+ }
+
+ wa-option {
+ white-space: nowrap;
+
+ &::before {
+ width: 1em;
+ margin-inline: var(--wa-space-xs);
+ }
+
+ &::part(checked-icon) {
+ order: 2;
+ }
+ }
+
+ .default-badge {
+ opacity: 0.6;
+ margin-inline-start: var(--wa-space-xs);
+ }
+}
+
+.swatch-select {
+ padding: 2px;
+
+ wa-radio-button {
+ --swatch-border-color: color-mix(in oklab, canvastext, transparent 80%);
+
+ &::part(base) {
+ /* a */
+ width: 2em;
+ height: 2em;
+ padding: 0;
+ border-radius: var(--border-radius, var(--wa-border-radius-m));
+ background: var(--color);
+ background-clip: border-box;
+ border-color: var(--swatch-border-color);
+ }
+ }
+
+ &.swatch-shape-circle {
+ --border-radius: var(--wa-border-radius-circle);
+ }
+
+ wa-radio-button:is([checked], :state(checked)) {
+ --swatch-border-color: var(--wa-color-surface-default);
+ &::part(base) {
+ box-shadow:
+ inset 0 0 0 var(--indicator-width) var(--wa-color-surface-default),
+ 0 0 0 calc(var(--indicator-width) + 1px) var(--indicator-color);
+ }
+ }
+
+ &::part(form-control-input) {
+ flex-wrap: wrap;
+ gap: var(--wa-space-xs);
+ }
+}
+
+/* Repeated to increase specificity */
+.editable-text.editable-text {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--wa-space-xs);
+ --edit-hint-color: oklab(from var(--wa-color-warning-fill-quiet) l a b / 50%);
+
+ > .text {
+ &:hover,
+ &:focus {
+ background-color: var(--edit-hint-color);
+ box-shadow: 0 0 0 var(--wa-space-2xs) var(--edit-hint-color);
+ color: inherit;
+ border-radius: calc(var(--wa-border-radius-m) - var(--wa-space-2xs));
+ }
+ }
+
+ > input {
+ font: inherit;
+ margin-block: calc(-1 * var(--wa-space-smaller));
+ field-sizing: content;
+ }
+
+ wa-icon-button {
+ font-size: 90%;
+ }
+}
+
+.info-tip-default-trigger {
+ color: var(--wa-color-text-quiet);
+
+ &:not(:hover, :focus) {
+ opacity: 65%;
+ }
+}
diff --git a/docs/assets/vue/components/color-select.js b/docs/assets/vue/components/color-select.js
new file mode 100644
index 000000000..d17e371e6
--- /dev/null
+++ b/docs/assets/vue/components/color-select.js
@@ -0,0 +1,78 @@
+import { capitalize } from '../../scripts/util/string.js';
+
+const template = `
+
+
+
+
+ {{ group }}
+
+
+
+
+
+ `;
+
+export default {
+ props: {
+ modelValue: String,
+ label: String,
+ getLabel: {
+ type: Function,
+ default: capitalize,
+ },
+ getContent: {
+ type: Function,
+ },
+ getColor: {
+ type: Function,
+ default: value => `var(--wa-color-${value})`,
+ },
+ values: {
+ type: Array,
+ default: [],
+ },
+ groups: {
+ type: Object,
+ },
+ },
+ emits: ['update:modelValue', 'input'],
+ data() {
+ return {};
+ },
+ computed: {
+ computedGroups() {
+ let ret = {};
+
+ if (this.values?.length) {
+ ret[''] = this.values;
+ }
+
+ if (this.groups) {
+ for (let group in this.groups) {
+ if (this.groups[group]?.length) {
+ ret[group] = this.groups[group];
+ }
+ }
+ }
+
+ return ret;
+ },
+
+ firstGroup() {
+ return Object.keys(this.computedGroups)[0];
+ },
+ },
+
+ methods: {
+ capitalize,
+ handleInput(e) {
+ this.$emit('input', this.modelValue);
+ },
+ },
+ template,
+ compilerOptions: {
+ isCustomElement: tag => tag.startsWith('wa-'),
+ },
+};
diff --git a/docs/assets/vue/components/editable-text.js b/docs/assets/vue/components/editable-text.js
new file mode 100644
index 000000000..670a7bfc0
--- /dev/null
+++ b/docs/assets/vue/components/editable-text.js
@@ -0,0 +1,82 @@
+const template = `
+
+
+
+
+
+
+
+ {{ value }}
+
+
+
+`;
+
+export default {
+ props: {
+ modelValue: String,
+ label: {
+ type: String,
+ default: 'Rename',
+ },
+ },
+ emits: ['update:modelValue', 'submit'],
+ data() {
+ return {
+ value: this.modelValue,
+ previousValue: undefined,
+ isEditing: false,
+ };
+ },
+ computed: {},
+
+ methods: {
+ edit(event) {
+ if (this.isEditing) {
+ return;
+ }
+
+ event.stopPropagation();
+
+ this.isEditing = true;
+ this.previousValue = this.value;
+
+ this.$nextTick(() => {
+ this.$refs.input.focus();
+ this.$refs.input.select();
+ });
+ },
+ done(event) {
+ if (!this.isEditing) {
+ return;
+ }
+
+ event.stopPropagation();
+
+ this.isEditing = false;
+
+ if (!this.previousValue || this.previousValue !== this.value) {
+ this.$emit('submit', this.value);
+ }
+ },
+ cancel(event) {
+ if (!this.isEditing) {
+ return;
+ }
+
+ event.stopPropagation();
+
+ this.isEditing = false;
+ this.value = this.previousValue;
+ },
+ handleInput(event) {
+ this.value = event.target.value;
+ },
+ },
+ watch: {
+ value(newValue) {
+ this.$emit('update:modelValue', newValue);
+ },
+ },
+ template,
+};
diff --git a/docs/assets/vue/components/fonts-card.js b/docs/assets/vue/components/fonts-card.js
new file mode 100644
index 000000000..abd7c56e2
--- /dev/null
+++ b/docs/assets/vue/components/fonts-card.js
@@ -0,0 +1,132 @@
+import PageCard from './page-card.js';
+import { defaultTitle, pairings, sameAs } from '/assets/data/fonts.js';
+import { themeConfig } from '/assets/data/theming.js';
+import { cssImport, getThemeCode } from '/assets/scripts/tweak/code.js';
+import themes from '/docs/themes/data.js';
+
+const template = `
+
+
+
+
+
+
+
+
+
+
When my six o'clock alarm buzzes, I require a pot of good java.
+
By quarter past seven, I've jotted hazy musings in a flax-bound notebook, sipping lukewarm espresso.
+
+
+
+
+
+
+
+
+
+`;
+
+export default {
+ props: {
+ theme: String,
+ src: String,
+ fonts: Object,
+ pairing: Object,
+ },
+
+ data() {
+ return {};
+ },
+
+ computed: {
+ content() {
+ let pairingTitle = this.computedPairing.title;
+ // let themeTitle = this.themeId ? `As seen in ${this.themeMeta.title}` : '';
+
+ if (this.title) {
+ return { title: this.title, subtitle: this.subtitle ?? pairingTitle };
+ } else {
+ return { title: pairingTitle, subtitle: this.subtitle };
+ }
+ },
+
+ url() {
+ let ret = this.src ?? this.pairing?.url;
+
+ if (!ret && this.theme) {
+ return themeConfig.typography.url(this.theme);
+ }
+
+ return ret;
+ },
+
+ themeId() {
+ return this.theme ?? this.pairing?.id;
+ },
+
+ themeMeta() {
+ return themes[this.themeId] ?? {};
+ },
+
+ computedFonts() {
+ let ret = this.fonts ?? this.pairing?.fonts ?? this.themeMeta?.fonts;
+ let defaults = themes.default.fonts;
+ return Object.assign({}, defaults, { ...ret });
+ },
+
+ computedPairing() {
+ let ret;
+
+ if (this.pairing) {
+ ret = { ...this.pairing };
+ } else {
+ // Get from theme
+ let fonts = this.computedFonts;
+ let { body, heading = sameAs.body } = fonts;
+ let pairing = pairings[body]?.[heading];
+ ret = Object.assign({ fonts }, pairing);
+ }
+
+ ret.url = this.url;
+ ret.title ??= defaultTitle(fonts);
+ return ret;
+ },
+
+ computed() {
+ let ret = { fonts: this.computedFonts };
+
+ for (let key in ret.fonts) {
+ if (ret.fonts[key] === sameAs.body) {
+ ret.fonts[key] = ret.fonts.body;
+ }
+ }
+
+ ret.pairing = this.computedPairing;
+ ret.theme = this.themeId;
+ ret.url = this.url;
+
+ return ret;
+ },
+
+ html() {
+ let { id, url } = this.computedPairing;
+
+ if (id) {
+ let theme = { typography: id };
+
+ return getThemeCode(theme, { id, language: 'html' });
+ } else {
+ return cssImport(url, { language: 'html' });
+ }
+ },
+ },
+
+ template,
+ components: {
+ PageCard,
+ },
+ compilerOptions: {
+ isCustomElement: tag => tag.startsWith('wa-'),
+ },
+};
diff --git a/docs/assets/vue/components/icons-card.js b/docs/assets/vue/components/icons-card.js
new file mode 100644
index 000000000..c23ccdfda
--- /dev/null
+++ b/docs/assets/vue/components/icons-card.js
@@ -0,0 +1,175 @@
+import { sample } from '../../scripts/util/array.js';
+import { capitalize } from '../../scripts/util/string.js';
+import PageCard from './page-card.js';
+import { iconLibraries } from '/assets/data/icons.js';
+
+const iconNames = [
+ 'user',
+ 'paper-plane',
+ 'face-laugh',
+ 'pen-to-square',
+ 'trash',
+ 'cart-shopping',
+ 'link',
+ 'sun',
+ 'bookmark',
+ 'sparkles',
+ 'thumbs-up',
+ 'gear',
+];
+const brands = new Set(['web-awesome', 'font-awesome']);
+const ICON_GRID = { columns: 6, rows: 2 };
+const TOTAL_ICONS = ICON_GRID.columns * ICON_GRID.rows;
+
+const template = `
+
+
+
+
+
+
+
+
+
+
+`;
+
+const defaultDefaults = {
+ library: 'default',
+ family: 'classic',
+ style: 'solid',
+};
+
+export default {
+ props: {
+ library: String,
+ family: String,
+ style: String,
+ defaults: Object,
+ type: {
+ type: String,
+ validate(value) {
+ return ['library', 'family', 'style'].includes(value);
+ },
+ },
+ vary: {
+ type: [Array, String],
+ validate(value) {
+ if (Array.isArray(value)) {
+ return value.every(v => ['family', 'style'].includes(v));
+ }
+
+ return ['family', 'style'].includes(value);
+ },
+ default() {
+ return [];
+ },
+ },
+ },
+
+ data() {
+ return {};
+ },
+
+ created() {
+ Object.assign(this, { iconNames, brands, ICON_GRID });
+ },
+
+ computed: {
+ computedLibrary() {
+ return this.library ?? 'default';
+ },
+
+ libraryMeta() {
+ return iconLibraries[this.computedLibrary] ?? {};
+ },
+
+ defaultTitle() {
+ let titles = {};
+ for (let key in this.computed) {
+ let value = this.computed[key];
+
+ if (key === 'library') {
+ titles[key] = iconLibraries[value].title;
+ }
+
+ titles[key] ??= capitalize(value);
+ }
+
+ if (this.type) {
+ return titles[this.type];
+ } else {
+ return titles.library + ' ' + titles.family + ' • ' + titles.style;
+ }
+ },
+
+ icons() {
+ let { family, style } = this.computed;
+ let library = this.libraryMeta;
+ let vary = Array.isArray(this.vary) ? this.vary : [this.vary];
+
+ let ret = [];
+
+ if (vary.length > 0) {
+ for (let param of vary) {
+ let allValues = library[param];
+ let random = (allValues.random ??= []);
+
+ while (random.length < TOTAL_ICONS) {
+ random.push(sample(allValues));
+ }
+ }
+ }
+
+ while (ret.length < TOTAL_ICONS) {
+ ret.push(
+ ...iconNames.map((name, i) => {
+ let index = ret.length + i;
+
+ return {
+ library: this.computedLibrary,
+ name,
+ family: !this.family && vary.includes('family') ? library.family.random[index] : family,
+ variant: !this.style && vary.includes('style') ? library.style.random[index] : style,
+ };
+ }),
+ );
+ }
+
+ return ret.slice(0, TOTAL_ICONS);
+ },
+
+ computedDefaults() {
+ return Object.assign({}, defaultDefaults, this.defaults);
+ },
+
+ computed() {
+ let { library, family, style } = this;
+ let ret = { library, family, style };
+
+ for (let key in this.computedDefaults) {
+ if (!ret[key]) {
+ ret[key] = this.computedDefaults[key];
+ }
+ }
+
+ return ret;
+ },
+
+ iconsMeta() {
+ return { title: this.defaultTitle };
+ },
+ },
+
+ methods: {
+ capitalize,
+ },
+
+ template,
+ components: {
+ PageCard,
+ },
+ compilerOptions: {
+ isCustomElement: tag => tag.startsWith('wa-'),
+ },
+};
diff --git a/docs/assets/vue/components/index.js b/docs/assets/vue/components/index.js
new file mode 100644
index 000000000..4a6668cb8
--- /dev/null
+++ b/docs/assets/vue/components/index.js
@@ -0,0 +1,12 @@
+export { default as ColorSelect } from './color-select.js';
+export { default as EditableText } from './editable-text.js';
+export { default as FontsCard } from './fonts-card.js';
+export { default as IconsCard } from './icons-card.js';
+export { default as InfoTip } from './info-tip.js';
+export { default as PageCard } from './page-card.js';
+export { default as PaletteCard } from './palette-card.js';
+export { default as SwatchSelect } from './swatch-select.js';
+export { default as ThemeCard } from './theme-card.js';
+export { default as UiPanelContainer } from './ui-panel-container.js';
+export { default as UiPanel } from './ui-panel.js';
+export { default as UiScrollable } from './ui-scrollable.js';
diff --git a/docs/assets/vue/components/info-tip.js b/docs/assets/vue/components/info-tip.js
new file mode 100644
index 000000000..a96b9b074
--- /dev/null
+++ b/docs/assets/vue/components/info-tip.js
@@ -0,0 +1,38 @@
+const template = `
+
+
+
+
+ `;
+
+let maxUid = 0;
+
+export default {
+ props: {
+ slot: String,
+ },
+ data() {
+ let uid = ++maxUid;
+ return { uid, id: 'info-tip-' + uid };
+ },
+ mounted() {
+ let tooltip = this.$refs.tooltip;
+ if (tooltip) {
+ // Find trigger
+ let trigger = tooltip.previousElementSibling;
+ if (trigger) {
+ if (trigger.id) {
+ // Already has id
+ this.id = trigger.id;
+ } else {
+ trigger.id = this.id;
+ }
+ }
+ }
+ },
+ computed: {},
+ template,
+ compilerOptions: {
+ isCustomElement: tag => tag.startsWith('wa-'),
+ },
+};
diff --git a/docs/assets/vue/components/page-card.js b/docs/assets/vue/components/page-card.js
new file mode 100644
index 000000000..242dd54c9
--- /dev/null
+++ b/docs/assets/vue/components/page-card.js
@@ -0,0 +1,83 @@
+/**
+ * Generic component for displaying a (possibly interactive) card that represents a page
+ * For more specific use cases check out theme-card, icons-card, etc.
+ */
+export const ICON_PLACEHOLDER = `
+
+
+
+
+ `;
+
+const template = `
+
+
+
+
+
+
+
+
+ {{ content.title }}
+ PRO
+ {{ content.subtitle }}
+
+
+
+
+
+
+`;
+
+export default {
+ props: {
+ title: String,
+ subtitle: String,
+ info: Object,
+ icon: String,
+ pro: Boolean,
+ disabled: Boolean,
+ action: Function,
+ },
+
+ data() {
+ return {};
+ },
+
+ created() {
+ Object.assign(this, { ICON_PLACEHOLDER });
+ },
+
+ computed: {
+ content() {
+ let defaultTitle = this.info?.title ?? {};
+
+ if (this.title) {
+ return { title: this.title, subtitle: this.subtitle ?? defaultTitle };
+ } else {
+ return { title: defaultTitle, subtitle: this.subtitle };
+ }
+ },
+ },
+
+ methods: {
+ handleClick(event) {
+ if (this.disabled) {
+ event.stopImmediatePropagation();
+ return;
+ }
+
+ if (this.action) {
+ this.action(event);
+ }
+ },
+ },
+
+ template,
+ components: {},
+ compilerOptions: {
+ isCustomElement: tag => tag.startsWith('wa-'),
+ },
+};
diff --git a/docs/assets/vue/components/palette-card.js b/docs/assets/vue/components/palette-card.js
new file mode 100644
index 000000000..3c521adb8
--- /dev/null
+++ b/docs/assets/vue/components/palette-card.js
@@ -0,0 +1,63 @@
+import PageCard from './page-card.js';
+import { hues } from '/assets/data/index.js';
+import palettes from '/docs/palettes/data.js';
+
+// TODO import from data.js once available
+const allHues = [...hues, 'gray'];
+
+const template = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+export default {
+ props: {
+ palette: String,
+ },
+
+ data() {
+ return {};
+ },
+
+ created() {
+ Object.assign(this, { hues: allHues, suffixes: ['-80', '', '-20'] });
+ },
+
+ computed: {
+ paletteMeta() {
+ return palettes[this.palette] ?? {};
+ },
+ },
+
+ template,
+ components: {
+ PageCard,
+ },
+ compilerOptions: {
+ isCustomElement: tag => tag.startsWith('wa-'),
+ },
+};
diff --git a/docs/assets/vue/components/panel.css b/docs/assets/vue/components/panel.css
new file mode 100644
index 000000000..301f59268
--- /dev/null
+++ b/docs/assets/vue/components/panel.css
@@ -0,0 +1,173 @@
+.sidebar.panel-container {
+ position: relative;
+ display: flex;
+ flex-flow: column;
+ gap: 0;
+ padding: 0;
+ width: 32ch;
+ overflow: hidden;
+ scrollbar-width: thin;
+}
+
+@keyframes back-icon-hover {
+ to {
+ transform: translateX(-0.2em);
+ }
+}
+
+.panel {
+ /* Remove the uniform spacing used in wa-details */
+ --spacing: 0;
+ /* Specify value to manually set spacing where needed */
+ --panel-spacing: var(--wa-space-2xl);
+ --panel-background: var(--wa-color-surface-default);
+
+ display: flex;
+ flex-flow: column;
+ max-height: 100%;
+ margin-bottom: 0;
+ position: relative;
+ background: var(--panel-background);
+ border: none;
+ transition:
+ translate var(--wa-transition-slow) allow-discrete,
+ opacity var(--wa-transition-slow) 25ms allow-discrete;
+ /* Ensure horizontal scrollbar isn't visible when translate takes effect */
+ overflow-x: hidden !important;
+
+ @starting-style {
+ display: block;
+ }
+
+ .panel-header {
+ flex-direction: row-reverse;
+ justify-content: start;
+ gap: var(--wa-space-xs);
+ cursor: pointer;
+ background: var(--panel-background);
+ color: var(--wa-color-text-normal);
+ padding-block-end: var(--panel-spacing);
+ padding-inline: var(--panel-spacing);
+ transition: inherit;
+ transition-property: all;
+ margin-block: 0;
+ font-size: inherit;
+
+ [data-step='0'] &,
+ .previous & {
+ padding-block-start: var(--panel-spacing);
+ }
+
+ .back-icon {
+ vertical-align: -0.15em;
+ margin-inline-end: var(--wa-space-xs);
+ font-size: var(--wa-font-size-m);
+ transition: transform var(--wa-transition-normal);
+ }
+
+ &:hover .back-icon {
+ animation: back-icon-hover var(--wa-transition-slow) alternate infinite;
+ }
+
+ label {
+ pointer-events: none;
+ font: inherit;
+ color: inherit;
+ }
+ }
+
+ .panel-content {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-flow: column;
+ gap: var(--panel-spacing);
+ padding-block-end: var(--panel-spacing);
+ padding-inline: var(--panel-spacing);
+ }
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ margin-block-end: 0;
+ }
+
+ &:not(.open) {
+ padding: 0;
+
+ &:not(.previous, .next) {
+ /* Hide all but the immediately preceding or following steps */
+ display: none;
+ }
+
+ &.next {
+ height: 0;
+ overflow: hidden;
+ }
+
+ &.next {
+ opacity: 0;
+ }
+
+ &.next {
+ translate: 100% 0%;
+ }
+
+ .panel-header {
+ font-size: var(--wa-font-size-s);
+ margin: 0;
+ }
+
+ .panel-content {
+ opacity: 0;
+ pointer-events: none;
+ content-visibility: hidden;
+ padding: 0;
+ }
+ }
+
+ &.open {
+ flex: 1;
+ opacity: 1;
+
+ .panel-header {
+ font-size: var(--wa-font-size-l);
+
+ .back-icon {
+ display: none;
+ }
+ }
+ }
+
+ .panel-content {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-flow: column;
+ transition: inherit;
+
+ @starting-style {
+ display: flex;
+ content-visibility: visible;
+ }
+ }
+
+ &:not(.open) {
+ &.previous {
+ .panel-content {
+ opacity: 0;
+ translate: -100% 0%;
+ }
+ }
+
+ &.next {
+ .panel-content {
+ opacity: 0;
+ translate: inherit;
+ }
+ }
+ }
+}
diff --git a/docs/assets/vue/components/scrollable.css b/docs/assets/vue/components/scrollable.css
new file mode 100644
index 000000000..4a49cc2d5
--- /dev/null
+++ b/docs/assets/vue/components/scrollable.css
@@ -0,0 +1,89 @@
+/**
+ * Scrollable element in a vertical flex container
+ * Showing shadows as an indicator of scrollability (PE wherever scroll-timeline is supported for now, can be polyfilled with JS later)
+ */
+
+.scrollable {
+ --scroll-shadow-height: 0.5em;
+
+ flex-shrink: 1;
+ min-height: 0;
+ overflow: auto;
+ position: relative;
+ scrollbar-width: inherit;
+
+ &:is(.panel-content > div) {
+ display: flex;
+ flex-flow: column;
+ gap: inherit;
+ }
+
+ .scroll-shadow {
+ position: sticky;
+ z-index: 1;
+ inset-inline: 0;
+ display: block;
+
+ &::before {
+ content: '';
+ position: absolute;
+ inset-inline: 0;
+ height: var(--scroll-shadow-height);
+ pointer-events: none;
+ mix-blend-mode: multiply;
+ background: radial-gradient(farthest-side, var(--wa-color-shadow) 10%, transparent) center / 120% 200%;
+ transition: var(--wa-transition-slow);
+ /* transition-property: opacity, transform, display, height, min-height; */
+ transition-behavior: allow-discrete;
+ }
+ }
+
+ &:not(.can-scroll-top) .scroll-shadow-top,
+ &:not(.can-scroll-bottom) .scroll-shadow-bottom {
+ opacity: 0;
+
+ &::before {
+ height: 0;
+ }
+ }
+
+ &:not(.can-scroll-top) .scroll-shadow-top {
+ &::before {
+ transform: translateY(-100%);
+ }
+ }
+
+ .scroll-shadow-top {
+ top: 0;
+
+ &::before {
+ background-position: bottom;
+ }
+ }
+
+ &:not(.can-scroll-bottom) .scroll-shadow-bottom {
+ &::before {
+ transform: translateY(100%);
+ }
+ }
+
+ .scroll-shadow-bottom {
+ top: 100%;
+
+ &::before {
+ bottom: 0;
+ background-position: top;
+ }
+ }
+}
+
+.scrollable:where(.panel-content) {
+ .scroll-shadow-top {
+ /* TODO convert this magic number to a token that explains what it is */
+ margin-bottom: -18px;
+ }
+
+ .scroll-shadow-bottom {
+ transform: translateY(var(--padding-bottom, var(--panel-spacing)));
+ }
+}
diff --git a/docs/assets/vue/components/swatch-select.js b/docs/assets/vue/components/swatch-select.js
new file mode 100644
index 000000000..abc71d787
--- /dev/null
+++ b/docs/assets/vue/components/swatch-select.js
@@ -0,0 +1,67 @@
+import { capitalize } from '../../scripts/util/string.js';
+import InfoTip from './info-tip.js';
+
+const template = `
+
+
+
+
+ {{ getLabel(value) }}
+
+
+
+ `;
+
+export default {
+ props: {
+ modelValue: String,
+ name: String,
+ label: String,
+ shape: {
+ type: String,
+ default: 'rounded',
+ validator: value => ['circle', 'rounded'].includes(value),
+ },
+ getLabel: {
+ type: Function,
+ default: capitalize,
+ },
+ getColor: {
+ type: Function,
+ default: value => `var(--wa-color-${value})`,
+ },
+ values: {
+ type: Array,
+ default: [],
+ },
+ },
+ emits: ['update:modelValue', 'input'],
+ data() {
+ return {
+ value: this.modelValue,
+ };
+ },
+ computed: {},
+
+ methods: {
+ capitalize,
+ handleInput(e) {
+ this.value = e.target.value;
+ this.$emit('input', this.value);
+ },
+ },
+
+ watch: {
+ value() {
+ this.$emit('update:modelValue', this.value);
+ },
+ },
+
+ template,
+ components: {
+ InfoTip,
+ },
+ compilerOptions: {
+ isCustomElement: tag => tag.startsWith('wa-'),
+ },
+};
diff --git a/docs/assets/vue/components/theme-card.js b/docs/assets/vue/components/theme-card.js
new file mode 100644
index 000000000..3b37ae048
--- /dev/null
+++ b/docs/assets/vue/components/theme-card.js
@@ -0,0 +1,97 @@
+import PageCard from './page-card.js';
+import { getThemeCode } from '/assets/scripts/tweak/code.js';
+import themes from '/docs/themes/data.js';
+
+const iconTemplates = {
+ colors: `
+
+
+ `,
+ theme: `
+
+
+
+ Go
+
`,
+};
+
+const template = `
+
+
+
+
+
+
+
+
+
+
+ ${iconTemplates.colors}
+
+
+
+ ${iconTemplates.theme}
+
+
+
+
+
+
+
+
+
+`;
+
+export default {
+ props: {
+ theme: String,
+ type: {
+ type: String,
+ validator(value) {
+ return !value || ['colors'].includes(value);
+ },
+ },
+ rest: Object,
+ },
+
+ data() {
+ return {};
+ },
+
+ computed: {
+ themeMeta() {
+ return themes[this.theme] ?? {};
+ },
+
+ themeCode() {
+ let theme = { ...(this.rest || {}), [this.type || 'base']: this.theme };
+ theme.base ||= 'default';
+
+ return getThemeCode(theme, { id: this.theme, language: 'html', cdnUrl: '/dist/' });
+ },
+ },
+
+ template,
+ components: {
+ PageCard,
+ },
+ compilerOptions: {
+ isCustomElement: tag => tag.startsWith('wa-'),
+ },
+};
diff --git a/docs/assets/vue/components/ui-panel-container.js b/docs/assets/vue/components/ui-panel-container.js
new file mode 100644
index 000000000..8997b5671
--- /dev/null
+++ b/docs/assets/vue/components/ui-panel-container.js
@@ -0,0 +1,120 @@
+const template = `
+
+`;
+
+export default {
+ props: {
+ /** Currently selected id */
+ modelValue: String,
+ },
+ emits: ['update:modelValue'],
+ data() {
+ return {
+ value: '',
+ previousValue: '',
+ step: 0,
+ trail: [],
+ };
+ },
+
+ mounted() {
+ let { container } = this.$refs;
+ let activePanel = container.querySelector(':scope > .open');
+
+ if (activePanel) {
+ let { step, value } = activePanel.dataset;
+ this.step = Number(step);
+ this.value = value;
+ this.$emit('update:modelValue', this.value);
+ }
+ },
+
+ computed: {
+ panels() {
+ if (!this.$refs.container) {
+ return new Map();
+ }
+
+ let { container } = this.$refs;
+
+ return new Map(
+ [...container.querySelectorAll(':scope > .panel')].map(panel => [
+ panel.dataset.value,
+ Number(panel.dataset.step),
+ ]),
+ );
+ },
+ },
+
+ methods: {
+ handleOpen(e) {
+ let { value, step } = e.detail;
+ this.value = value;
+ this.step = step;
+ },
+
+ updatePanels() {
+ let { container } = this.$refs;
+
+ if (!container) {
+ return;
+ }
+
+ let { step, value } = this;
+
+ if (this.panels.get(value) !== step) {
+ // Hasn't stabilized yet
+ return;
+ }
+
+ let previousValue = this.trail.findLast(panel => this.panels.get(panel) === step - 1);
+
+ for (let panel of container.querySelectorAll(':scope > .panel')) {
+ let panelStep = Number(panel.dataset.step);
+ let panelValue = panel.dataset.value;
+ let isPrevious = previousValue ? panelValue === previousValue : panelStep === step - 1;
+ let isOpen = panelValue === value;
+ let isNext = panelStep === step + 1;
+
+ panel.classList.toggle('previous', isPrevious);
+ panel.classList.toggle('open', isOpen);
+ panel.classList.toggle('next', isNext);
+ }
+ },
+ },
+
+ watch: {
+ value() {
+ if (this.value !== this.modelValue) {
+ this.$emit('update:modelValue', this.value);
+ }
+ },
+
+ modelValue: {
+ immediate: true,
+ async handler(value, previousValue) {
+ if (this.value !== this.modelValue) {
+ this.value = this.modelValue;
+ }
+
+ if (previousValue) {
+ this.trail.push(previousValue);
+ }
+
+ this.updatePanels();
+ },
+ },
+
+ step() {
+ this.updatePanels();
+ },
+ },
+
+ template,
+ components: {},
+ compilerOptions: {
+ isCustomElement: tag => tag.startsWith('wa-'),
+ },
+};
diff --git a/docs/assets/vue/components/ui-panel.js b/docs/assets/vue/components/ui-panel.js
new file mode 100644
index 000000000..c336d3a20
--- /dev/null
+++ b/docs/assets/vue/components/ui-panel.js
@@ -0,0 +1,73 @@
+import UiScrollable from './ui-scrollable.js';
+
+const template = `
+
+
+
+
+
+
+`;
+
+export default {
+ props: {
+ title: String,
+ name: String,
+ step: Number,
+
+ /** Id of this panel */
+ value: String,
+
+ /** Currently selected id */
+ modelValue: String,
+ },
+ emits: ['update:modelValue', 'open'],
+ data() {
+ return {};
+ },
+
+ mounted() {
+ if (this.open) {
+ this.$refs.panelHeader.dispatchEvent(
+ new CustomEvent('open', { detail: { value: this.value, step: this.step }, bubbles: true }),
+ );
+ }
+ },
+
+ computed: {
+ open() {
+ return this.value === this.modelValue;
+ },
+ },
+
+ methods: {
+ openPanel() {
+ let wasOpen = this.open;
+ this.$emit('update:modelValue', wasOpen ? '' : this.value);
+ },
+ },
+
+ watch: {
+ open: {
+ immediate: true,
+ handler(open) {
+ if (open && this.$refs.panelHeader) {
+ this.$refs.panelHeader.dispatchEvent(
+ new CustomEvent('open', { detail: { value: this.value, step: this.step }, bubbles: true }),
+ );
+ }
+ },
+ },
+ },
+
+ template,
+ components: {
+ UiScrollable,
+ },
+ compilerOptions: {
+ isCustomElement: tag => tag.startsWith('wa-'),
+ },
+};
diff --git a/docs/assets/vue/components/ui-scrollable.js b/docs/assets/vue/components/ui-scrollable.js
new file mode 100644
index 000000000..a23c0491a
--- /dev/null
+++ b/docs/assets/vue/components/ui-scrollable.js
@@ -0,0 +1,77 @@
+const template = `
+
+`;
+
+export default {
+ props: {
+ disabled: Boolean,
+ },
+ data() {
+ return {
+ scrollTop: 0,
+ scrollHeight: 0,
+ height: 0,
+ };
+ },
+
+ mounted() {
+ let { container, content } = this.$refs;
+ container.addEventListener('scroll', this.handleScroll, { passive: true });
+
+ this.scrollHeight = container.scrollHeight;
+ this.height = container.clientHeight;
+ },
+
+ computed: {
+ canScrollTop() {
+ return !this.disabled && this.scrollTop > 1;
+ },
+
+ maxScrollTop() {
+ return this.scrollHeight - this.height;
+ },
+
+ canScrollBottom() {
+ return !this.disabled && this.scrollTop < this.maxScrollTop - 1;
+ },
+
+ scrollProgress() {
+ return this.scrollTop / this.maxScrollTop;
+ },
+
+ scrollProgressEnd() {
+ return this.scrollProgress + this.maxScrollTop / this.scrollHeight;
+ },
+
+ scrollBottom() {
+ return this.scrollHeight * this.scrollProgressEnd;
+ },
+ },
+
+ methods: {
+ handleScroll(event) {
+ let { container } = this.$refs;
+ this.scrollTop = container.scrollTop;
+ },
+ },
+
+ watch: {
+ scrollTop(value, oldValue) {
+ let { container } = this.$refs;
+ if (container && oldValue === 0) {
+ this.scrollHeight = container.scrollHeight;
+ this.height = container.clientHeight;
+ }
+ },
+ },
+
+ template,
+ components: {},
+ compilerOptions: {
+ isCustomElement: tag => tag.startsWith('wa-'),
+ },
+};
diff --git a/docs/assets/vue/directives/content.js b/docs/assets/vue/directives/content.js
new file mode 100644
index 000000000..893c5acae
--- /dev/null
+++ b/docs/assets/vue/directives/content.js
@@ -0,0 +1,22 @@
+// Like v-text, but doesn't complain if the element has content,
+// making it possible to use in a PE fashion, with the contents being the fallback
+export default function content(el, { value, arg }) {
+ if (!el.dataset.fallback) {
+ // Store the original content as a fallback the first time
+ el.dataset.fallback = el.textContent;
+ }
+
+ if (value === '') {
+ value = el.dataset.fallback;
+ } else {
+ if (arg === 'number') {
+ value = Number(value).toLocaleString(undefined, { maximumSignificantDigits: 2 });
+ }
+ }
+
+ if (arg === 'html') {
+ el.innerHTML = value;
+ } else {
+ el.textContent = value;
+ }
+}
diff --git a/docs/assets/vue/mixins/saved.js b/docs/assets/vue/mixins/saved.js
new file mode 100644
index 000000000..18dc53d5d
--- /dev/null
+++ b/docs/assets/vue/mixins/saved.js
@@ -0,0 +1,110 @@
+import my from '/assets/scripts/my.js';
+import Permalink from '/assets/scripts/permalink.js';
+
+export default {
+ data() {
+ return {
+ uid: undefined,
+ saved: null,
+ unsavedChanges: false,
+ permalink: new Permalink(),
+ };
+ },
+
+ created() {
+ if (this.permalink.has('uid')) {
+ this.uid = Number(this.permalink.get('uid'));
+ this.saved = this.controller.saved.find(p => p.uid === this.uid);
+ }
+
+ this.controller.addEventListener('delete', ({ detail: entity }) => {
+ if (entity.uid === this.saved?.uid) {
+ this.postDelete();
+ }
+ });
+ },
+
+ mounted() {
+ this.$nextTick().then(() => {
+ if (!location.search || this.saved) {
+ this.unsavedChanges = false;
+ }
+ });
+ },
+
+ computed: {
+ controller() {
+ return my[this.collection];
+ },
+
+ title() {
+ if (this.saved) {
+ return this.saved.title;
+ } else if (this.unsavedChanges) {
+ return this.defaultTitle;
+ } else {
+ return this.originalTitle;
+ }
+ },
+ },
+
+ watch: {
+ saved: {
+ deep: true,
+ handler() {
+ this.unsavedChanges = !this.saved;
+ },
+ },
+ },
+
+ methods: {
+ async save({ title } = {}) {
+ let uid = this.uid;
+
+ this.saved ??= { uid: this.uid };
+ this.saved.id = this.id;
+
+ if (title) {
+ // Renaming
+ this.saved.title = title;
+ } else {
+ this.saved.title ??= this.defaultTitle;
+ }
+
+ this.saved.search = location.search;
+
+ this.saved = this.controller.save(this.saved);
+
+ if (uid !== this.saved.uid) {
+ // UID changed (most likely from saving a new entity)
+ this.uid = this.saved.uid;
+ this.permalink.set('uid', this.uid);
+ this.permalink.updateLocation();
+ await this.$nextTick();
+ this.save(); // Save again to update the search param to include the UID
+ }
+
+ this.unsavedChanges = false;
+ },
+
+ rename() {
+ let newTitle = prompt('Title:', this.saved?.title ?? this.defaultTitle);
+
+ if (newTitle && newTitle !== this.saved?.title) {
+ this.save({ title: newTitle });
+ }
+ },
+
+ // Cannot name this delete() because Vue complains
+ deleteSaved() {
+ this.controller.delete(this.saved);
+ },
+
+ postDelete() {
+ this.saved = null;
+ this.permalink.delete('uid');
+ this.uid = undefined;
+ this.permalink.updateLocation();
+ },
+ },
+};
diff --git a/docs/docs/components/animation.md b/docs/docs/components/animation.md
index 8865f76c5..12cc733f0 100644
--- a/docs/docs/components/animation.md
+++ b/docs/docs/components/animation.md
@@ -77,9 +77,9 @@ This example demonstrates all of the baked-in animations and easings. Animations
easingName.appendChild(option);
});
- animationName.addEventListener('wa-change', () => (animation.name = animationName.value));
- easingName.addEventListener('wa-change', () => (animation.easing = easingName.value));
- playbackRate.addEventListener('wa-input', () => (animation.playbackRate = playbackRate.value));
+ animationName.addEventListener('change', () => (animation.name = animationName.value));
+ easingName.addEventListener('change', () => (animation.easing = easingName.value));
+ playbackRate.addEventListener('input', () => (animation.playbackRate = playbackRate.value));
```
@@ -64,10 +54,10 @@ Headers can be used to display titles and more.
If using SSR, you need to also use the `with-header` attribute to add a header to the card (if not, it is added automatically).
```html {.example}
-
-
+
diff --git a/docs/docs/components/page.md b/docs/docs/components/page.md
index f23f0b893..a81ad9cbd 100644
--- a/docs/docs/components/page.md
+++ b/docs/docs/components/page.md
@@ -168,7 +168,7 @@ It can be opened using a button with `[data-toggle-nav]` that appears in the `su
Discover More Birds
-
+
@@ -177,7 +177,7 @@ It can be opened using a button with `[data-toggle-nav]` that appears in the `su
Asio otus
-
+
@@ -186,7 +186,7 @@ It can be opened using a button with `[data-toggle-nav]` that appears in the `su
Surnia ulula
-
+
@@ -885,4 +885,4 @@ If you don’t want to use [native styles](/docs/native/), you can include this
```html
-```
+```
\ No newline at end of file
diff --git a/docs/docs/components/page/demo-page.css b/docs/docs/components/page/demo-page.css
index 327d5bd38..aa6d2548c 100644
--- a/docs/docs/components/page/demo-page.css
+++ b/docs/docs/components/page/demo-page.css
@@ -52,8 +52,8 @@ wa-page {
}
&[slot^='navigation'] {
- background: var(--wa-color-violet-80);
- color: var(--wa-color-violet-20);
+ background: var(--wa-color-purple-80);
+ color: var(--wa-color-purple-20);
}
strong {
diff --git a/docs/docs/components/popup.md b/docs/docs/components/popup.md
index 8a8fc7b94..f18a7d9f4 100644
--- a/docs/docs/components/popup.md
+++ b/docs/docs/components/popup.md
@@ -54,11 +54,11 @@ Popup is a low-level utility built specifically for positioning elements. Do not
const active = container.querySelector('wa-switch[name="active"]');
const arrow = container.querySelector('wa-switch[name="arrow"]');
- select.addEventListener('wa-change', () => (popup.placement = select.value));
- distance.addEventListener('wa-input', () => (popup.distance = distance.value));
- skidding.addEventListener('wa-input', () => (popup.skidding = skidding.value));
- active.addEventListener('wa-change', () => (popup.active = active.checked));
- arrow.addEventListener('wa-change', () => (popup.arrow = arrow.checked));
+ select.addEventListener('change', () => (popup.placement = select.value));
+ distance.addEventListener('input', () => (popup.distance = distance.value));
+ skidding.addEventListener('input', () => (popup.skidding = skidding.value));
+ active.addEventListener('change', () => (popup.active = active.checked));
+ arrow.addEventListener('change', () => (popup.arrow = arrow.checked));
-
-
```
### Flip
-When the popup doesn't have enough room in its preferred placement, it can automatically flip to keep it in view. To enable this, use the `flip` attribute. By default, the popup will flip to the opposite placement, but you can configure preferred fallback placements using `flip-fallback-placement` and `flip-fallback-strategy`. Additional options are available to control the flip behavior's boundary and padding.
+When the popup doesn't have enough room in its preferred placement, it can automatically flip to keep it in view and visually connected to its anchor.
+To enable this, use the `flip` attribute. By default, the popup will flip to the opposite placement, but you can configure preferred fallback placements using `flip-fallback-placement` and `flip-fallback-strategy`. Additional options are available to control the flip behavior's boundary and padding.
+
+By default, flip takes effect when the popup would overflow the viewport.
+You can use `boundary="scroll"` to make the popup resize when it overflows its nearest scrollable container instead.
Scroll the container to see how the popup flips to prevent clipping.
```html {.example}