diff --git a/_includes/feature-table.html b/_includes/feature-table.html new file mode 100644 index 0000000..c18a95c --- /dev/null +++ b/_includes/feature-table.html @@ -0,0 +1,198 @@ + + + + + +The table below aims to track implemented features in popular engines and tools. +You can click on a cell for more information. + + + +

+ Loading table, please wait… + (report issues) +

+ +
+
+
+ Currently showing categories: +
+ +
+
+
+ Report issues + • + Contribute data +
+
+ +
+ + + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + diff --git a/_includes/header.html b/_includes/header.html index a7ead2a..af931ed 100644 --- a/_includes/header.html +++ b/_includes/header.html @@ -16,8 +16,11 @@ + {% if page.url == "/features/" %} + + {% endif %} diff --git a/css/custom.css b/css/custom.css index 6178555..7818ed7 100644 --- a/css/custom.css +++ b/css/custom.css @@ -25,7 +25,7 @@ audio:not([controls]) { height: 0; } [hidden] { - display: none; + display: none !important; } html { font-size: 100%; @@ -2447,8 +2447,6 @@ dl dd { } table { - /* https://bugzilla.mozilla.org/show_bug.cgi?id=1005271 */ - /* display: block; */ width: 100%; overflow: auto; } @@ -2522,200 +2520,16 @@ pre code::after { content: normal; } -#feature-support-scrollbox { - width: min-content; - max-width: 95vw; - margin: 0 50%; - margin-bottom: 2em; - transform: translateX(-50%); - overflow-x: auto; -} - -#feature-support-scrollbox th[scope='row'] { - position: sticky; - left: -1px; - background-color: inherit; - z-index: 1; -} - -#feature-support-scrollbox a::after { - /* Hide external link symbols on the table, since they are all external. */ - display: none; -} - -@media (min-width: 1400px) { - #feature-support { - white-space: nowrap; - } -} - -#feature-support { - cursor: default; -} - -#feature-support > caption { - text-align: left; -} - -#feature-support sup { - padding-left: 1pt; -} - -#feature-support tr > * { - text-align: center; -} - -#feature-support tr:first-child > th { - vertical-align: bottom; - white-space: normal; -} - -#feature-support .img-container { - width: 32px; - height: 32px; -} - -#feature-support td { - position: relative; /* for tooltip */ -} - -#feature-support td:hover, -#feature-support td:focus, -#feature-support td:focus-within { - background: rgba(0, 0, 0, 0.04); -} - -#feature-support th img { - max-height: 32px; -} - -.feature-cell { - position: relative; - height: 24px; /* height of the icon inside */ - line-height: 24px; -} - -.feature-cell > sup { - font-size: 0.7em; - position: absolute; - top: 0.2em; -} - -.feature-cell > svg { - width: 24px; - height: 24px; -} - -.feature-cell.icon-yes { - color: #1b5e20; -} - -.feature-cell.icon-yes > svg .svg-stroke { - fill: #1b5e20; -} - -.feature-cell.icon-no { - color: #a96e8e; -} - -.feature-cell.icon-no > svg .svg-stroke { - fill: #a96e8e; -} - -.feature-cell.icon-flag { - color: #575581; -} - -.feature-cell.icon-flag > svg .svg-stroke { - fill: #575581; -} - -.feature-cell.icon-na { - color: #78909c; -} - -.feature-cell.icon-unknown > svg .svg-stroke { - fill: #78909c; -} - -#feature-support-scrollbox + ol { - list-style: lower-alpha; - font-size: 0.7em; - margin: 0 0 1em 0; - columns: 32em auto; - column-gap: 2em; -} - -#feature-support-scrollbox + ol > li { - transition: background-color 0.08s ease-in-out; -} - -#feature-support-scrollbox + ol .ref-highlight { - background: #eceff1; -} - -.feature-tooltip { - text-align: left; - text-align: start; - white-space: normal; - color: #000; - background: #fefefe; - font-size: 0.8em; - border-radius: 2px; - outline: none; - - top: 0; - left: 0; - z-index: 1; - max-width: 16em; - width: max-content; - height: max-content; - padding: 12px; -} - -/* Only apply transition after the initial position was set */ -.feature-tooltip[data-placement] { - transition: transform 0.2s ease-in-out; -} - -.feature-tooltip, -.feature-tooltip-arrow { +.visually-hidden { + /* Visually hidden */ position: absolute; - contain: layout style; - --shadow-size: 3px; - box-shadow: 0 0 var(--shadow-size) rgba(0, 0, 0, 0.3); -} - -.feature-tooltip-arrow { - --arrow-size: 8px; - background: inherit; - width: var(--arrow-size); - height: var(--arrow-size); - - --c0: calc(var(--shadow-size) * -1); - --c1: calc(100% + var(--shadow-size)); - clip-path: polygon( - var(--c0) var(--c1), - var(--c0) var(--c0), - var(--c1) var(--c0) - ); -} - -[data-placement='top'] > .feature-tooltip-arrow { - bottom: 0; - transform: translateY(50%) rotate(-135deg); -} -[data-placement='bottom'] > .feature-tooltip-arrow { - top: 0; - transform: translateY(-50%) rotate(45deg); -} -[data-placement='left'] > .feature-tooltip-arrow { - right: 0; - transform: translateX(50%) rotate(135deg); -} -[data-placement='right'] > .feature-tooltip-arrow { - left: 0; - transform: translateX(-50%) rotate(-45deg); + width: 1px; + height: 1px; + padding: 0; + border: none; + white-space: nowrap; + overflow: hidden; + clip: rect(0, 0, 0, 0); } dark-mode-toggle { diff --git a/css/dark.css b/css/dark.css index f8a5218..4609b4d 100644 --- a/css/dark.css +++ b/css/dark.css @@ -1,13 +1,26 @@ :root { color-scheme: dark; + + --color-fg: #e5e5e5; + --color-bg: #1e1e1e; + --color-bg-highlight: rgb(255 255 255 / 4%); + --color-bg-secondary: #202020; + --color-link: #7cb1e2; + --color-link-visited: #ab8fee; + --color-border: #404549; + --color-border-primary: #2a4872; + + color: var(--color-fg); + background-color: var(--color-bg); } +.invert-in-dark-theme, :not(.flash) > a[href^='http']::after { - filter: invert(1); + filter: invert(0.8); } .flash.flash-warn { - color: CanvasText; + color: var(--color-fg); background-color: #555; } @@ -20,30 +33,45 @@ } .lead { - color: #999; + color: #c7c7c7; } blockquote { - color: #999; + color: #c7c7c7; } h6 { - color: #999; + color: #c7c7c7; } -table th, -table td { - border: 1px solid #222; +.text-secondary { + color: rgb(255 255 255 / 75%); } -table tr { - border-top: 1px solid #333; +#feature-table table { + --color-bg: #202020; + --color-status-dim: #444c50; + background-color: var(--color-bg); } -table tr:nth-child(2n) { - background-color: #303030; +#feature-table :is(.cell, .details-status) > * { + filter: brightness(2); } -.feature-cell { - filter: brightness(2); +#feature-table :is(th, .cell) { + border-left: none; +} + +.status-yes { + color: #305934; + fill: #3d4e3e; +} + +.status-no { + color: #785562; +} + +.status-experimental { + color: #3e5997; + fill: #3f63b7; } diff --git a/css/feature-table.css b/css/feature-table.css new file mode 100644 index 0000000..7787042 --- /dev/null +++ b/css/feature-table.css @@ -0,0 +1,429 @@ +[x-cloak] { + display: none !important; +} + +@keyframes appear-suddenly { + 0% { + opacity: 0; + } + 95% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +#feature-table-loading { + animation: 1s linear forwards appear-suddenly; +} + +#feature-table { + margin-bottom: 0.75rem; +} + +#feature-table template { + /* Prevent templates from affecting layout. */ + display: none !important; +} + +#feature-table a { + text-decoration: none; + color: var(--color-link); +} + +#feature-table a:visited { + color: var(--color-link-visited); +} + +#feature-table a:hover { + text-decoration: underline; +} + +#feature-table table a::after, +#feature-table-loading a::after { + /* Hide external link symbols in the table, since they are all external. */ + display: none; +} + +#feature-table svg { + width: auto; /* Required for Safari */ + height: 100%; +} + +#feature-table fieldset { + border: none; + padding: 0; + margin: 0; +} + +#table-actions { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: end; + gap: 1.5rem; + row-gap: 0.75rem; + font-size: .9rem; +} + +#table-actions legend { + font-weight: 600; +} + +#platform-filters { + display: flex; /* Flexbox does not work with
due to a bug */ + flex-direction: column; + gap: 0.2rem; + margin-top: 0.2rem; +} + +.platform-filter { + flex: 1 1 auto; + display: flex; + align-items: center; + gap: 6px; + user-select: none; +} + +.platform-filter > * { + cursor: pointer; +} + +.platform-filter > input { + margin-top: 2px; /* Align checkbox with label */ +} + +/* Style as button groups on desktop. Threshold should be revised when categories change. */ +@media (min-width: 50rem) { + #table-actions legend { + /* Visually hidden */ + position: absolute; + width: 1px; + height: 1px; + padding: 0; + border: none; + white-space: nowrap; + overflow: hidden; + clip: rect(0, 0, 0, 0); + } + + #platform-filters { + flex-direction: row; + gap: 0; + max-width: 95vw; + overflow: auto visible; + background-color: var(--color-bg-secondary); + } + + .platform-filter { + font-size: 0.95rem; + padding: 0.3rem 0.75rem; + padding-inline-start: 0.6rem; + gap: 0.6rem; + min-width: max-content; + + --border: 1px solid var(--color-border); + border: var(--border); + } + + .platform-filter:first-of-type { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + } + + .platform-filter:last-of-type { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + } + + .platform-filter > input { + transform: scale(1.05); + } + + .platform-filter:has(input:checked) { + --color-border: var(--color-border-primary); + } + + /* Combo 1: checked|R| + |L|checked => hide |L| border */ + /* Combo 2: any|R| + |L|unchecked => hide |L| border */ + .platform-filter:has(input:checked) + .platform-filter:has(input:checked), + .platform-filter + .platform-filter:has(input:not(:checked)) { + border-left: none; + } + + /* Combo 3: unchecked|R| + |L|checked => hide |R| border */ + .platform-filter:has(input:not(:checked)):has( + + .platform-filter > input:checked + ) { + border-right: none; + } + + .platform-filter:has(input:focus-visible) { + box-shadow: inset 0 0 0 0.25rem rgb(57 177 255 / 50%); + } +} + +#feature-scroll { + width: max-content; + min-width: 100%; + max-width: 95vw; + margin: 0 50%; + margin-top: 0.85rem; + transform: translateX(-50%); + overflow-x: auto; +} + +table { + width: min-content; + min-width: 100%; + margin: 0; + overflow: initial; /* Required for sticky headers */ + + display: grid; + grid-auto-flow: dense; + justify-content: center; + align-items: stretch; + + --feature-column-min-width: 12rem; + grid-template-columns: minmax(var(--feature-column-min-width), 2fr) repeat( + calc(var(--num-columns) - 1), + 1fr + ); + + --border: 1px solid var(--color-border); + border: var(--border); + border-radius: 4px; + --cell-padding-block: 8px; + --cell-padding-inline: 10px; + --color-status-dim: #78909c; + + font-size: 0.9rem; + line-height: 1; +} + +thead, +tbody, +tr { + display: contents; +} + +table th { + font-weight: 600; + padding: 8px 12px; +} + +th, +.cell { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + padding: var(--cell-padding-block) var(--cell-padding-inline); +} + +.platform-header-row th { + display: grid; + grid-template-rows: subgrid; + grid-row: span 2; + justify-content: stretch; +} + +.platform-header-row th > * { + display: inline-grid; + justify-items: center; + row-gap: 2px; + align-self: end; + + /* Applies to cells without logo (e.g. Your browser) */ + grid-row: span 2; +} + +@supports not (grid-template-columns: subgrid) { + .platform-header-row th > * { + align-self: start; + } +} + +.platform-header-row th > :has(img) { + /* Align logo and name using parent grid lines. */ + grid-template-rows: inherit; + grid-row: inherit; +} + +.platform-header-row th > :has(img) .platform-name { + white-space: nowrap; +} + +.platform-header-row .rounded { + border-radius: 3px; +} + +thead .category { + grid-column: span var(--num-columns-in-category); + font-size: 0.8rem; + opacity: 0.85; +} + +tbody tr:first-of-type th { + grid-column: 1 / -1; +} + +th[scope='row'] { + text-wrap: balance; + line-height: 1.4; + position: sticky; + left: -1px; + z-index: 1; + background-color: var(--color-bg); +} + +th img { + max-width: 100%; + max-height: 1.65rem; + height: auto; + margin: 2px 0 4px; +} + +.cell { + -webkit-appearance: none; + appearance: none; + background: none; + border: none; + justify-content: start; + gap: 5px; + touch-action: manipulation; +} + +.cell .icon { + width: 1.25rem; + height: 1.25rem; +} + +button.cell { + cursor: pointer; + border-bottom: var(--border); + border-bottom-width: 2px; + border-bottom-color: transparent; /* Prevent border from shifting cell height */ +} + +button.cell:hover { + background: var(--color-bg-highlight); +} + +button.cell[aria-expanded='true'] { + border-bottom-color: var(--color-border); +} + +td { + display: contents; +} + +table > :first-child tr:not(:first-child) :is(th, .cell), +table > :not(:first-child) tr :is(th, .cell), +.details { + border-top: var(--border); +} + +tr > th:not(:first-child), +tr > :not(:first-child) .cell { + border-left: var(--border); +} + +.text-secondary { + font-weight: normal; + color: rgb(0 0 0 / 75%); +} + +.status-yes { + color: #0c3d10; + fill: #5c6b5d; +} + +.status-no { + color: #83264a; + fill: #86556f; +} + +.status-experimental { + color: #143a93; + fill: #3f63b7; +} + +.status-not-applicable, +.status-unknown, +.details-note svg { + color: #52656f; + fill: var(--color-status-dim); +} + +:is(.status-unknown, .status-not-applicable, .status-experimental) svg { + padding: 1.5px; +} + +.icon-note svg { + padding: 1.5px; + fill: var(--color-status-dim); +} + +.details { + grid-column: 1 / -1; + display: grid; + grid-template-columns: subgrid; +} + +@supports not (grid-template-columns: subgrid) { + .details { + grid-template-columns: var(--feature-column-min-width, 12em) 1fr; + } +} + +.details-inner { + grid-column: 2 / -1; + max-width: 95vw; + margin: 0; + padding: calc(var(--cell-padding-block) * 2.5) var(--cell-padding-inline); + display: grid; + grid-template-columns: max-content 1fr; + align-items: center; + row-gap: var(--cell-padding-inline); + column-gap: calc(var(--cell-padding-inline) * 0.85); + line-height: 1.35; +} + +@media (width < 1000px) { + .details-inner { + /* Flush left on smaller screen */ + grid-column: 1 / -1; + position: sticky; + left: -1px; + z-index: 1; + } +} + +.details-inner > * { + display: contents; +} + +.details-inner .icon { + height: 1.2rem; + height: 1lh; +} + +.details-inner svg { + padding: 2px 0; +} + +.details-status { + font-weight: 600; +} + +.details-note svg { + opacity: 0.6; +} + +.details-note-line { + grid-column: 2 / 2; + max-width: 90%; +} diff --git a/css/light.css b/css/light.css index cded12b..7700546 100644 --- a/css/light.css +++ b/css/light.css @@ -1,5 +1,17 @@ :root { color-scheme: light; + + --color-fg: #151515; + --color-bg: #fefefe; + --color-bg-highlight: rgb(0 0 0 / 5%); + --color-bg-secondary: rgb(0 0 0 / 1.25%); + --color-link: #1751a7; + --color-link-visited: #5817a7; + --color-border: rgb(0 0 0 / 15%); + --color-border-primary: #7facec; + + color: var(--color-fg); + background-color: var(--color-bg); } .flash.flash-warn { @@ -12,7 +24,7 @@ } .lead { - color: #555; + color: #3a3a3a; } blockquote { @@ -23,15 +35,6 @@ h6 { color: #777; } -table th, -table td { - border: 1px solid #ddd; -} - -table tr { - border-top: 1px solid #ccc; -} - -table tr:nth-child(2n) { - background-color: #f8f8f8; +#feature-table .top-corner { + border-top: none !important; } diff --git a/features.js b/features.js index 5184672..516b3a0 100644 --- a/features.js +++ b/features.js @@ -1,484 +1,525 @@ -(async () => { - 'use strict'; +'use strict'; + +/*! groupby-polyfill. MIT License. Jimmy Wärting */ +/** + * Groups elements from an iterable into an object based on a callback function. + * + * @template T, K + * @param {Iterable} iterable - The iterable to group. + * @param {function(T, number): K} callbackfn - The callback function to + * determine the grouping key. + * @returns {Object.} An object where keys are the grouping keys + * and values are arrays of grouped elements. + * + * This was introduced because of https://github.com/GoogleChromeLabs/wasm-feature-detect/issues/82. + */ +Object.groupBy ??= function groupBy(iterable, callbackfn) { + const obj = Object.create(null); + let i = 0; + for (const value of iterable) { + const key = callbackfn(value, i++); + key in obj ? obj[key].push(value) : (obj[key] = [value]); + } + return obj; +}; + +/** + * `Array.map` but for object values. + * + * @template {object} T + * @template R + * @param {T} obj + * @param {(value: T[keyof T], key: keyof T) => R} mapper + * @returns {{ [K in keyof T]: R }} + */ +function mapValues(obj, mapper) { + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => [key, mapper(value, key)]) + ); +} + +/** + * Break a string into three parts using the given delimiter. + * @param {string} str + * @param {string} delim + * @returns {[string, string, string]} + */ +function splitParts(str, delim) { + const start = str.indexOf(delim); + const end = str.indexOf(delim, start + 1); + if (start >= 0 && end > start) { + const head = str.substring(0, start); + const body = str.substring(start + 1, end); + const tail = str.substring(end + 1); + return [head, body, tail]; + } + return [str, '', '']; +} + +function loadFeatureDetection() { + // Please cache bust by bumping the `v` parameter whenever `feature.json` is + // updated to depend on a new version of the library. See #353 for discussion. + // Make sure to also match the preload link in `feature-table.html`. + const module = + import('https://unpkg.com/wasm-feature-detect@1/dist/esm/index.js?v=1'); + return (featureName) => + module.then((wasmFeatureDetect) => wasmFeatureDetect[featureName]()); +} + +const container = document.getElementById('feature-table'); + +/** + * @typedef {{ + * type: 'yes' | 'no' | 'not-applicable' | 'experimental' | 'unknown'; + * version?: string; + * note?: string; + * expanded?: boolean; + * }} DecodedStatus + */ +/** @typedef {null | boolean | 'flag' | string} RawState */ /** + * @param {RawState | [RawState, string]} status + */ +function decodeSupportStatus(status) { + // Meaning of each entry: + // * null => not applicable to this browser + // * true/false => supported/unsupported + // * "version" => supported since "version" + // * "flag" => flag required (must be lowercase) + // * [true, "footnotes"] => supported, with "footnotes" + // * ["version", "footnotes"] => supported since "version", with "footnotes" + // …and any combination thereof + + /** @type {RawState} */ + let state, note; + if (Array.isArray(status)) { + if (status.length !== 2) throw new TypeError(); + [state, note] = status; + } else { + state = status; + } - function h(name, props = {}, children = []) { - const node = Object.assign(document.createElement(name), props); - node.append(...children); - return node; + /** @type {DecodedStatus['type']} */ + let type, version; + if (typeof state === 'string') { + type = state === 'flag' ? 'experimental' : 'yes'; + version = state !== 'flag' ? state : undefined; + } else if (!state) { + type = state === null ? 'not-applicable' : 'no'; + } else { + if (state !== true) + throw new TypeError( + `unexpected supported status ${JSON.stringify(state)}` + ); + type = 'yes'; } - // Convert number to lowercase hexavigesimal like "a, b, c, .., x, y, z, aa, ab, ..", starting from zero. - // This is the same format as CSS `list-style: lower-alpha`, which is used for our footnote lists. - function toAlphabet(num) { - const digit = num % 26, - char = String.fromCharCode(97 + digit), - rem = num - digit; - return rem ? toAlphabet(Math.floor(rem / 26) - 1) + char : char; + return { type, version, note, expanded: false }; +} + +/** @typedef {{ name: string, queryKey: string, default?: boolean }} Category */ + +/** @param {Category[]} allCategories */ +function loadSelectedCategories(allCategories) { + const names = new URLSearchParams(location.search) + .getAll('categories') + .flatMap((values) => values.split(',')) + .flatMap((param) => { + const category = allCategories.find(({ queryKey }) => queryKey === param); + return category ? [category.name] : []; + }); + + return names.length + ? names + : allCategories + .filter((category) => category.default) + .map((category) => category.name); +} + +/** + * @param {Category[]} allCategories + * @param {string[]} selected + */ +function saveSelectedCategories(allCategories, selected) { + const defaultSelection = allCategories + .filter((category) => category.default) + .map((category) => category.name); + + if ( + selected.length === defaultSelection.length && + selected.every((name) => defaultSelection.includes(name)) + ) { + selected = []; } - // Map names to HTML ids. For example, idMap['table-col']('Chrome') will return 'table-col-chrome'. - // This is to satisfy the need for unique ids in `headers` attributes. - // Hardcoded array makes it easier to find typos, since it would throw an error if the namespace is mistyped. - const idMap = ['table-group', 'table-col', 'table-row'].reduce( - (map, namespace) => { - map[namespace] = (str) => - namespace + '-' + str.toLowerCase().replace(/[^\w\d-_]+/g, '-'); - return map; - }, - {} - ); + // Keep the same order as in `allCategories` + const queryKeys = allCategories + .filter(({ name }) => selected.includes(name)) + .map(({ queryKey }) => queryKey); - // Get a copy of the requested SVG icon. Those are defined in the markdown as templates. - function icon(key) { - return document - .getElementById(`support-symbol-${key}`) - .content.firstElementChild.cloneNode(true); + const url = new URL(location.href); + if (queryKeys.length) { + url.searchParams.set('categories', queryKeys.join(',')); + } else { + url.searchParams.delete('categories'); } - const scrollbox = document.getElementById('feature-support-scrollbox'); - const table = document.getElementById('feature-support'); - - const detectWasmFeature = _loadFeatureDetectModule(); - const addTooltip = _loadTooltipModule(); - - const { features, browsers } = await fetch('/features.json', { - credentials: 'include', // https://stackoverflow.com/a/63814972 - mode: 'no-cors', - }).then((res) => res.json()); - - const tBody = document.createElement('tbody'); - table.append( - h('thead', {}, [ - h('tr', {}, [ - h('th', { id: 'table-blank' }), - h('th', { scope: 'col', id: idMap['table-col']('Your browser') }, [ - 'Your browser', - ]), - ...Object.entries(browsers).map(([name, { url, logo }]) => - h('th', { scope: 'col', id: idMap['table-col'](name) }, [ - h('a', { href: url, target: '_blank' }, [ - // Empty alt trick: https://www.w3.org/WAI/WCAG22/Techniques/html/H2 - h('img', { src: logo, alt: '' }), - h('br'), - name, - ]), - ]) - ), - ]), - ]), - tBody - ); + history.replaceState(null, '', url); +} + +// TODO: think of a cleaner way to store icons +const statusIcons = mapValues( + { + yes: 'icon-check', + no: 'icon-close', + 'not-applicable': 'icon-forbid-2', + experimental: 'icon-flask', + unknown: 'icon-question-mark', + asterisk: 'icon-asterisk', + more: 'icon-more', + loading: 'icon-loading', + }, + (id) => /** @type {DocumentFragment} */ (document.getElementById(id).content) +); + +const noteIcons = mapValues( + { + yes: 'icon-checkbox-circle', + no: 'icon-close-circle', + 'not-applicable': 'icon-forbid-2', + experimental: 'icon-flask', + unknown: 'icon-checkbox-blank-circle', + }, + (id) => /** @type {DocumentFragment} */ (document.getElementById(id).content) +); + +/** + * @typedef {{ + * name: string; + * url: string; + * logo: string; + * logoClassName?: string; + * category: string; + * categories: string[]; + * features: Record + * }} Platform + */ + +const state = () => ({ + /** @type {Platform[]} */ + platforms: [], + + /** @type {Record} */ + yourBrowser: {}, + + /** @type {{ name: string; features: object[] }[]} */ + featureGroups: [], + + /** @type {Category[]} */ + categories: [], + + get categoryNames() { + return this.categories.map(({ name }) => name); + }, + + /** @type {string[]} */ + selectedCategories: [], + + async init() { + const { + features, + categories, + browsers: platforms, + } = await fetch('/features.json', { + credentials: 'include', // https://stackoverflow.com/a/63814972 + mode: 'no-cors', + }).then((res) => res.json()); + + const categoriesInUse = new Set( + Object.values(platforms).flatMap(({ category }) => category) + ); - /*! groupby-polyfill. MIT License. Jimmy Wärting */ + // Hide empty categories. + this.categories = categories.filter(({ name }) => + categoriesInUse.has(name) + ); + this.selectedCategories = loadSelectedCategories(categories); + + this.platforms = Object.entries(platforms).map( + ([name, { category, ...platform }]) => { + // Determine the primary category. + let categories = []; + if (Array.isArray(category)) { + categories = category; + category = category[0]; + } - /** - * Groups elements from an iterable into an object based on a callback function. - * - * @template T, K - * @param {Iterable} iterable - The iterable to group. - * @param {function(T, number): K} callbackfn - The callback function to - * determine the grouping key. - * @returns {Object.} An object where keys are the grouping keys - * and values are arrays of grouped elements. - * - * This was introduced because of https://github.com/GoogleChromeLabs/wasm-feature-detect/issues/82. - */ - Object.groupBy ??= function groupBy(iterable, callbackfn) { - const obj = Object.create(null); - let i = 0; - for (const value of iterable) { - const key = callbackfn(value, i++); - key in obj ? obj[key].push(value) : (obj[key] = [value]); - } - return obj; - }; - - let featureGroups = Object.groupBy( - Object.entries(features).map(([name, feature]) => - Object.assign(feature, { name }) - ), - (f) => f.phase - ); + // Decode the compact status format for easier future processing. + const platformFeatures = mapValues(features, (_, featName) => { + const raw = platform.features[featName]; + return typeof raw === 'undefined' + ? { type: 'no' } // Missing values default to 'no' + : decodeSupportStatus(raw); + }); - featureGroups = [ - { - name: 'Phase 5 - The Feature is Standardized', - features: featureGroups[5], - }, - { name: 'Phase 4 - Standardize the Feature', features: featureGroups[4] }, - { name: 'Phase 3 - Implementation Phase', features: featureGroups[3] }, - { - name: 'Phase 2 - Proposed Spec Text Available', - features: featureGroups[2], - }, - { name: 'Phase 1 - Feature Proposal', features: featureGroups[1] }, - { name: 'Inactive', features: featureGroups['inactive'] }, - ]; - - // Collect all notes and assign an index to each unique item - // { "First unique note": 0, "Second unique note": 1, ...} - const notes = Object.values(browsers).flatMap((b) => - Object.values(b.features) - .filter((s) => Array.isArray(s)) - .map((s) => s[1]) - ); - const note2index = new Map(); - let noteIndex = 0; - for (const note of notes) { - if (!note2index.has(note)) { - note2index.set(note, noteIndex++); - } - } + return { + name, + ...platform, + category, + categories, + features: platformFeatures, + }; + } + ); - // Generate the footnote list. They are later referenced in the actual table. - const noteList = document.createElement('ol'); - // Place footnote list outside of the scolling area - scrollbox.parentNode.insertBefore(noteList, scrollbox.nextSibling); - for (const [note, index] of note2index) { - const item = h('li', { id: `feature-note-${index}` }); - noteList.appendChild(item).appendChild(renderNote(note)); - } + let featureByGroup = Object.groupBy( + Object.entries(features).map(([id, feature]) => + Object.assign(feature, { id }) + ), + (f) => f.phase + ); - // Create an element that links to the specified footnote. - // Also returns the HTML id of the footnote it refers to. - function createNoteRef(index) { - const id = `feature-note-${index}`; - return [id, h('a', { href: `#${id}` }, [`[${toAlphabet(index)}]`])]; - } + this.featureGroups = [ + { + name: 'Phase 5 – The Feature is Standardized', + features: featureByGroup[5], + }, + { + name: 'Phase 4 – Standardize the Feature', + features: featureByGroup[4], + }, + { name: 'Phase 3 – Implementation Phase', features: featureByGroup[3] }, + { + name: 'Phase 2 – Proposed Spec Text Available', + features: featureByGroup[2], + }, + { name: 'Phase 1 – Feature Proposal', features: featureByGroup[1] }, + { name: 'Inactive', features: featureByGroup['inactive'] }, + ]; + + const featureDetect = loadFeatureDetection(); + for (const id of Object.keys(features)) { + featureDetect(id) + .then((supported) => { + this.yourBrowser[id] = { + type: supported ? 'yes' : 'no', + version: supported ? 'Yes' : undefined, + }; + }) + .catch(() => { + this.yourBrowser[id] = { type: 'unknown' }; + }); + } - const columnCount = 2 + Object.keys(browsers).length; + document.getElementById('feature-table-loading')?.remove(); + }, - for (const { name: groupName, features } of featureGroups) { - if (!features) { - continue; + onSelectedCategoryChange(value, oldValue) { + if (!value.length && this.categories.length) { + // Prevent user from deselecting all categories. + this.selectedCategories = this.categoryNames.filter( + (name) => !oldValue.includes(name) + ); } - tBody.append( - h('tr', {}, [ - h( - 'th', - { - scope: 'colgroup', - colSpan: columnCount, - id: idMap['table-group'](groupName), - headers: 'table-blank', - // Chrome doesn't handle `headers` attribute correctly. - // Just hide the group headers for now… - // https://bugs.chromium.org/p/chromium/issues/detail?id=1081201 - // - // Actually Firefox doesn't support `ariaHidden` attribute. - // This is a happy coincidence, since `headers` works fine on Firefox anyway. - ariaHidden: true, - }, - [groupName] - ), - ]) - ); - for (const { name: featName, description, url } of features) { - const detectResult = h( - 'td', + saveSelectedCategories(this.categories, this.selectedCategories); + }, + + /** + * Returns the cells to be rendered in a specific feature row + * (or null for the header row), excluding the row header. + * + * @param {string | null} featureId + * @returns {(Omit, 'features'> & { name: string; category: string; status?: DecodedStatus | undefined; })[]} + */ + cellsForRow(featureId) { + const selected = new Set(this.selectedCategories); + const cells = [ + { + name: 'Your browser', + category: 'Web Browsers', + features: this.yourBrowser, + }, + ...this.platforms, + ].flatMap(({ category, features, ...platform }) => { + if (!selected.has(category)) { + // Look for the next available option if the primary category is not selected. + category = platform.categories?.find((category) => + selected.has(category) + ); + + // Skip the platform if none of its categories are selected,. + if (!category) return []; + } + + return [ { - headers: [ - idMap['table-col']('Your browser'), - idMap['table-row'](featName), - ].join(' '), + ...platform, + category, + status: featureId ? features[featureId] : undefined, }, - [buildCellInner('loading')] - ); + ]; + }); - detectWasmFeature(featName).then( - (supported) => { - detectResult.textContent = ''; - detectResult.appendChild(buildCellInner(supported ? 'yes' : 'no')); - addTooltip( - detectResult, - supported ? '✓ Supported' : '✗ Not supported', - [tBody, scrollbox] - ); - }, - (_err) => { - detectResult.textContent = ''; - detectResult.appendChild(buildCellInner('unknown')); - addTooltip(detectResult, 'Detection unavailable for this feature', [ - tBody, - scrollbox, - ]); - } - ); - - tBody.append( - h('tr', {}, [ - h( - 'th', - { - scope: 'row', - id: idMap['table-row'](featName), - headers: idMap['table-group'](groupName), - }, - [h('a', { href: url, target: '_blank' }, [description])] - ), - detectResult, - ...Object.entries(browsers).map(([browserName, { features }]) => { - // Meaning of each entry: - // * null => not applicable for this browser - // * true/false => supported/unsupported - // * "version" => supported since "version" - // * "flag" => flag required (must be lowercase) - // * [true, "footnotes"] => supported, with "footnotes" - // * ["version", "footnotes"] => supported since "version", with "footnotes" - // …and any combination thereof - - /** @type {null|boolean|string|[boolean|string,string]} */ - let support = features[featName]; - let box, note; - - // First extract the footnote part if it's an array - if (Array.isArray(support)) { - if (support.length !== 2) throw new TypeError(); - note = support[1]; - support = support[0]; - } - - if (typeof support === 'string') { - if (support === 'flag') { - // 'flag' is treated specially as the "requires flag" icon - box = buildCellInner('flag'); - } else { - // Otherwise it's a version number, like "95" - box = buildCellInner('yes', support); - note ||= `✓ Supported since version ${support}`; - } - } else if (!support) { - if (support === null) { - box = buildCellInner('na', 'N/A'); - note ||= '✗ Not applicable for this browser/engine'; - } else { - box = buildCellInner('no'); - note ||= '✗ Not supported'; - } - } else { - if (support !== true) throw new TypeError(); - box = buildCellInner('yes'); - // Magic value, keep in sync with `renderNote` - note ||= '✓ Supported, introduced in unknown version'; - } - - const cell = h( - 'td', - { - headers: [ - idMap['table-col'](browserName), - idMap['table-row'](featName), - ].join(' '), - }, - [box] - ); + const { categoryNames } = this; + return cells.sort( + (a, b) => + categoryNames.indexOf(a.category) - categoryNames.indexOf(b.category) + ); + }, - // Give the cell itself an `aria-lebel` to avoid screen readers calling it "empty cell". - const icon = box.firstElementChild; - if (icon?.hasAttribute('aria-label')) { - cell.setAttribute('aria-label', icon.getAttribute('aria-label')); - icon.removeAttribute('aria-label'); - } - - if (note && note2index.has(note)) { - cell.tabIndex = 0; // focusable - const index = note2index.get(note); - const [noteId, refLink] = createNoteRef(index); - box.appendChild(h('sup', {}, [refLink])); - - // Accommodate the width of elements, which are absolutely positioned - box.style.paddingInline = `${toAlphabet(index).length}ch`; - - const noteItem = document.getElementById(noteId); - if (noteItem) { - cell.addEventListener('mouseenter', () => - noteItem.classList.add('ref-highlight') - ); - cell.addEventListener('mouseleave', () => - noteItem.classList.remove('ref-highlight') - ); - } - } - - // Clip to both and the scrollbox. - // the former is to avoid blocking out the headers; - // the latter is to keep the tooltip inside the scrollable area - addTooltip(cell, note, [tBody, scrollbox]); - return cell; - }), - ]) - ); - tBody.lastElementChild.setAttribute( - 'aria-describedby', - idMap['table-row'](featName) - ); + /** The categories currently displayed and their number of columns. */ + get cellGroupsForRow() { + return mapValues( + Object.groupBy(this.cellsForRow(null), ({ category }) => category), + (platforms) => platforms.length + ); + }, + + get numColumns() { + return 1 + this.cellsForRow(null).length; + }, + + /** @param {DecodedStatus} selected */ + toggleFeatureDetails(selected) { + if (selected.expanded) { + selected.expanded = false; + } else { + // Only one should be open at a time, close everything else first. + for (const platform of this.platforms) + for (const feat of Object.values(platform.features)) + feat?.expanded && (feat.expanded = false); + + for (const feat of Object.values(this.yourBrowser)) + feat?.expanded && (feat.expanded = false); + + selected.expanded = true; } - } + }, + + /** @param {DecodedStatus | undefined} status */ + classForStatus(status) { + if (!status?.type) return null; + return `status-${status.type}`; + }, + + /** @param {DecodedStatus | undefined} status */ + iconForStatus(status) { + if (!status?.type) return statusIcons['loading']; + return statusIcons[status.type]; + }, + + get iconMoreDetails() { + return statusIcons['more']; + }, + + get iconNote() { + return statusIcons['asterisk']; + }, + + /** @param {DecodedStatus | undefined} status */ + iconForNote(status) { + if (!status?.type) return noteIcons['unknown']; + return noteIcons[status.type] ?? noteIcons['unknown']; + }, + + /** @param {DecodedStatus | undefined} status */ + labelForStatus(status) { + if (!status) return null; + if (status.version) return status.version; + switch ( + status.type + // case 'no': + // return 'No'; + // case 'not-applicable': + // return 'N/A'; + ) { + } + return null; + }, - function buildCellInner(type, text) { - const content = text || icon(type); - return h('div', { className: `feature-cell icon-${type}` }, [content]); - } + /** + * @param {DecodedStatus | undefined} status + * @param {string | null} platformName + */ + detailsLabelForStatus(status, platformName) { + if (!status?.type) return null; + switch (status.type) { + case 'yes': + if (platformName === 'Your browser') return 'Supported in your browser'; + if (status.version) { + return `Supported in ${platformName} ${status.version}`; + } else { + const fragment = document.createDocumentFragment(), + note = document.createElement('span'); + note.className = 'text-secondary'; + note.textContent = '(version unknown)'; + fragment.append(`Supported in ${platformName} `, note); + return fragment; + } + case 'no': + if (platformName === 'Your browser') + return 'Not supported in your browser'; + return `Not supported in ${platformName}`; + case 'experimental': + return `Experimental support in ${platformName}`; + case 'not-applicable': + return `This feature is not applicable to ${platformName}`; + case 'unknown': + return 'Detection unavailable for this feature'; + } + throw new TypeError(); + }, - function renderNote(note) { - const fragment = document.createDocumentFragment(); - const isMissingData = note.includes('introduced in unknown version'); + /** @param {string} note */ + renderNote(note) { + if (!note) return note; // Transform markdown-like backticks into html + const fragment = document.createDocumentFragment(); while (note) { const [head, body, tail] = splitParts(note, '`'); head && fragment.append(head); - body && fragment.appendChild(h('code', {}, [body])); - note = tail; - } - - const firstNode = fragment.firstChild; - if (firstNode.nodeType === Node.TEXT_NODE) { - // No point for screen readers to pronounce those symbols out loud. - for (const symbol of ['✓', '✗']) { - if (firstNode.nodeValue?.startsWith(symbol)) { - // Before: <#text>✓ Supported - // After: <#text> Supported - firstNode.splitText(1); - const symbolNode = h('span', {}, firstNode.nodeValue); - symbolNode.setAttribute('aria-hidden', ''); - fragment.replaceChild(symbolNode, firstNode); - break; - } + if (body) { + const el = document.createElement('code'); + el.textContent = body; + fragment.appendChild(el); } + note = tail; } - - if (isMissingData) { - fragment.appendChild( - h( - 'a', - { - href: 'https://github.com/WebAssembly/website/blob/master/features.json', - target: '_blank', - }, - [' (contribute data)'] - ) - ); - } - return fragment; - } - - // Break a string into three parts using the given delimiter. - function splitParts(str, delim) { - const start = str.indexOf(delim); - const end = str.indexOf(delim, start + 1); - if (start >= 0 && end > start) { - const head = str.substring(0, start); - const body = str.substring(start + 1, end); - const tail = str.substring(end + 1); - return [head, body, tail]; + }, + + /** @param {string} s */ + str2id(s) { + return s.replaceAll(/\W+/g, '-').toLowerCase(); + }, +}); + +document.addEventListener('alpine:init', () => { + // A custom direction `x-replace` to directly insert DOM nodes into the document. + // This avoids HTML parsing and is much more performant than `x-html`. + Alpine.directive( + 'replace', + ( + /** @type {Element} */ el, + { expression, modifiers }, + { evaluateLater, effect } + ) => { + const clone = modifiers.includes('clone'); + const getChild = evaluateLater(expression); + effect(() => + getChild((child) => { + if (Array.isArray(child)) + throw new TypeError( + 'x-replace cannot operate on arrays, use DocumentFragment instead' + ); + if (clone && child instanceof Node) + child = document.importNode(child, true); + el.replaceChildren(child); + }) + ); } - return [str, '', '']; - } - - // Lazy-loading - function _loadTooltipModule() { - // Be sure to change the preloads in markdown when updating url. - // The ESM bundle of this package doesn't work with unpkg.com. - const module = - import('https://cdn.jsdelivr.net/npm/@floating-ui/dom@1/+esm'); - - const subscribers = new Set(); - const updateAll = () => { - for (const fn of subscribers) fn(); - }; - - document.addEventListener('scroll', updateAll, { passive: true }); - scrollbox.addEventListener('scroll', updateAll, { passive: true }); - window.addEventListener('resize', updateAll, { passive: true }); - - let counter = 0; - return (reference, note, boundary) => - module.then(({ computePosition, offset, flip, shift, arrow }) => { - const tooltipId = `tooltip-${counter++}`; - const tooltip = h('div', { - id: tooltipId, - className: 'feature-tooltip', - role: 'tooltip', - }); - tooltip.appendChild(renderNote(note)); - - const arrowElement = h('div', { className: 'feature-tooltip-arrow' }); - tooltip.appendChild(arrowElement); - - const update = () => - computePosition(reference, tooltip, { - placement: 'top', - middleware: [ - offset(6), - flip({ boundary }), - shift({ padding: 6, boundary }), - arrow({ element: arrowElement, padding: 3, boundary }), - ], - }).then(({ x, y, placement, middlewareData }) => { - const { x: arrowX, y: arrowY } = middlewareData.arrow; - Object.assign(arrowElement.style, { - left: arrowX !== null ? `${arrowX}px` : '', - top: arrowY !== null ? `${arrowY}px` : '', - }); - - tooltip.style.transform = `translate(${x}px, ${y}px)`; - // Force the browser to apply CSS changes first - if (tooltip.dataset.placement !== placement) tooltip.offsetHeight; - // This will then enable the transition effect - tooltip.dataset.placement = placement; - }); - - const setVisible = (visible) => { - if (visible) { - tooltip.style.removeProperty('display'); - update(); - subscribers.add(update); - } else { - subscribers.delete(update); - tooltip.style.display = 'none'; - delete tooltip.dataset.placement; // disable the transition effect - } - }; - - setVisible(false); - - const monitor = (name, state, listener = () => setVisible(state)) => - reference.addEventListener(name, listener); - monitor('focusin', true); - monitor('focusout', false); - - // Add a bit of delay to mouse events - let timeout = null; - monitor('mouseenter', true, () => { - clearTimeout(timeout); - if (subscribers.size) { - timeout = setTimeout(() => setVisible(true), 80); - } else { - // Immediately show if there aren't other tooltips visible - setVisible(true); - } - }); - monitor('mouseleave', false, () => { - clearTimeout(timeout); - timeout = setTimeout(() => setVisible(false), 80); - }); - - reference.appendChild(tooltip); - reference.setAttribute('aria-describedby', tooltipId); - return tooltip; - }); - } + ); - function _loadFeatureDetectModule() { - // Please cache bust by bumping the `v` parameter whenever `feature.json` is - // updated to depend on a new version of the library. See #353 for discussion. - // Make sure to also match the preload link in `features.md`. - const module = - import('https://unpkg.com/wasm-feature-detect@1/dist/esm/index.js?v=1'); - return (featureName) => - module.then((wasmFeatureDetect) => wasmFeatureDetect[featureName]()); - } -})(); + Alpine.data('data', state); +}); diff --git a/features.json b/features.json index c0c9166..4a295f4 100644 --- a/features.json +++ b/features.json @@ -252,10 +252,17 @@ "phase": 3 } }, + "categories": [ + { "name": "Web Browsers", "queryKey": "browsers", "default": true }, + { "name": "Standalone Runtimes", "queryKey": "standalones", "default": true }, + { "name": "Embedded Runtimes", "queryKey": "embeddeds" }, + { "name": "Tools", "queryKey": "tools" } + ], "browsers": { "Chrome": { "url": "https://www.google.com/chrome/", "logo": "/images/chrome.svg", + "category": "Web Browsers", "features": { "bigInt": "85", "branchHinting": "137", @@ -298,6 +305,7 @@ "Firefox": { "url": "https://www.mozilla.org/firefox/", "logo": "/images/firefox.svg", + "category": "Web Browsers", "features": { "bigInt": "78", "branchHinting": [ @@ -341,10 +349,11 @@ "Safari": { "url": "https://www.apple.com/safari/", "logo": "/images/safari.svg", + "category": "Web Browsers", "features": { "bigInt": [ "15", - "wasm-bigint is supported in desktop Safari since 14.1 and iOS Safari since 14.5; however BigInt64Array, which is needed by Emscripten, was released in 15" + "`wasm-bigint` is supported in desktop Safari since 14.1 and iOS Safari since 14.5; however `BigInt64Array`, which is needed by Emscripten, was released in 15" ], "branchHinting": "16", "bulkMemory": "15", @@ -383,6 +392,7 @@ "Node.js": { "url": "https://nodejs.org/", "logo": "/images/nodejs.svg", + "category": "Standalone Runtimes", "features": { "bigInt": "15.0", "branchHinting": [ @@ -433,6 +443,7 @@ "Deno": { "url": "https://deno.com/", "logo": "/images/deno.svg", + "category": "Standalone Runtimes", "features": { "bigInt": "1.1.2", "branchHinting": "2.3.2", @@ -475,6 +486,7 @@ "GraalWasm": { "url": "https://www.graalvm.org/webassembly/", "logo": "/images/graalvm.svg", + "category": "Standalone Runtimes", "features": { "bigInt": "21.3", "bulkMemory": "23.0", @@ -499,6 +511,7 @@ "Chicory": { "url": "https://chicory.dev/", "logo": "/images/chicory.svg", + "category": "Standalone Runtimes", "features": { "bigInt": null, "bulkMemory": "1.0.0", @@ -523,6 +536,7 @@ "Wasmtime": { "url": "https://wasmtime.dev/", "logo": "/images/bca.svg", + "category": "Standalone Runtimes", "features": { "bigInt": null, "bulkMemory": "0.20", @@ -561,6 +575,7 @@ "Wasmer": { "url": "https://wasmer.io/", "logo": "/images/wasmer.svg", + "category": "Standalone Runtimes", "features": { "bigInt": null, "bulkMemory": "1.0", @@ -580,39 +595,10 @@ "webContentSecurityPolicy": null } }, - "wasm2c": { - "url": "https://github.com/WebAssembly/wabt", - "logo": "/images/wasm2c.svg", - "features": { - "bigInt": null, - "bulkMemory": "1.0.30", - "customAnnotationSyntaxInTheTextFormat": null, - "exceptionsFinal": ["flag", "Requires flag `--enable-exceptions`"], - "exceptions": ["flag", "Requires flag `--enable-exceptions`"], - "extendedConst": ["flag", "Requires flag `--enable-extended-const`"], - "esmIntegration": null, - "jspi": null, - "jsStringBuiltins": null, - "memory64": ["flag", "Requires flag `--enable-memory64`"], - "multiMemory": ["flag", "Requires flag `--enable-multi-memory`"], - "tailCall": ["flag", "Requires flag `--enable-tail-call`"], - "customPageSizes": [ - "flag", - "Requires flag `--enable-custom-page-sizes`" - ], - "multiValue": "1.0.24", - "mutableGlobals": "1.0.1", - "referenceTypes": "1.0.31", - "saturatedFloatToInt": "1.0.24", - "signExtensions": "1.0.24", - "simd": "1.0.33", - "typeReflection": null, - "webContentSecurityPolicy": null - } - }, "wizard": { "url": "https://github.com/titzer/wizard-engine", "logo": "/images/wizard.svg", + "category": "Standalone Runtimes", "features": { "bigInt": null, "bulkMemory": "25", @@ -625,7 +611,6 @@ "jspi": null, "jsStringBuiltins": null, "memory64": "25", - "multiMemory": "25", "tailCall": "25", "customPageSizes": ["flag", "Requires flag `--ext:custom-page-sizes`"], "multiValue": "21", @@ -646,6 +631,8 @@ "wazero": { "url": "https://wazero.io", "logo": "/images/wazero.svg", + "logoClassName": "invert-in-dark-theme", + "category": "Standalone Runtimes", "features": { "bigInt": null, "bulkMemory": true, @@ -658,8 +645,7 @@ "jspi": null, "jsStringBuiltins": null, "memory64": false, - "multiMemory": false, - "tailCall": ["flag", "experimental feature"], + "tailCall": "flag", "customPageSizes": null, "multiValue": true, "multiMemory": false, @@ -670,15 +656,48 @@ "signExtensions": true, "simd": true, "stackSwitching": false, - "threads": ["flag", "experimental feature"], + "threads": "flag", "typedFunctionReferences": false, "typeReflection": null, "webContentSecurityPolicy": null } }, + "wasm2c": { + "url": "https://github.com/WebAssembly/wabt", + "logo": "/images/wasm2c.svg", + "category": "Standalone Runtimes", + "features": { + "bigInt": null, + "bulkMemory": "1.0.30", + "customAnnotationSyntaxInTheTextFormat": null, + "exceptionsFinal": ["flag", "Requires flag `--enable-exceptions`"], + "exceptions": ["flag", "Requires flag `--enable-exceptions`"], + "extendedConst": ["flag", "Requires flag `--enable-extended-const`"], + "esmIntegration": null, + "jspi": null, + "jsStringBuiltins": null, + "memory64": ["flag", "Requires flag `--enable-memory64`"], + "multiMemory": ["flag", "Requires flag `--enable-multi-memory`"], + "tailCall": ["flag", "Requires flag `--enable-tail-call`"], + "customPageSizes": [ + "flag", + "Requires flag `--enable-custom-page-sizes`" + ], + "multiValue": "1.0.24", + "mutableGlobals": "1.0.1", + "referenceTypes": "1.0.31", + "saturatedFloatToInt": "1.0.24", + "signExtensions": "1.0.24", + "simd": "1.0.33", + "typeReflection": null, + "webContentSecurityPolicy": null + } + }, "Owi": { "url": "https://github.com/ocamlpro/owi", - "logo": "/images/owi.png", + "logo": "/images/owi.webp", + "logoClassName": "rounded", + "category": ["Tools", "Standalone Runtimes"], "features": { "bigInt": null, "bulkMemory": true, @@ -691,7 +710,6 @@ "jspi": null, "jsStringBuiltins": null, "memory64": false, - "multiMemory": false, "tailCall": true, "customPageSizes": false, "multiValue": true, @@ -712,6 +730,7 @@ "Binaryen": { "url": "https://github.com/WebAssembly/binaryen", "logo": "/images/binaryen.svg", + "category": "Tools", "features": { "bigInt": true, "branchHinting": true, @@ -726,7 +745,6 @@ "jspi": true, "jsStringBuiltins": true, "memory64": true, - "multiMemory": true, "tailCall": true, "customPageSizes": false, "multiValue": true, @@ -747,6 +765,7 @@ "wasm-language-tools": { "url": "https://github.com/g-plane/wasm-language-tools", "logo": "/images/wasm-language-tools.svg", + "category": "Tools", "features": { "bigInt": null, "branchHinting": null, diff --git a/features.md b/features.md index 51e8767..7a3920b 100644 --- a/features.md +++ b/features.md @@ -16,33 +16,7 @@ For the complete list of current proposals and their respective stages, check out the [`WebAssembly/proposals` repo](https://github.com/WebAssembly/proposals). - - - - - - - - -The table below aims to track implemented features in popular engines: - -
-
-
- - - - +{% include feature-table.html %} To detect supported features at runtime from JavaScript, check out the [`wasm-feature-detect` library](https://github.com/GoogleChromeLabs/wasm-feature-detect), diff --git a/features.schema.json b/features.schema.json index f70ec19..cb5c965 100644 --- a/features.schema.json +++ b/features.schema.json @@ -24,13 +24,34 @@ "properties": { "url": { "type": "string", "format": "uri" }, "logo": { "type": "string", "format": "uri-reference" }, + "logoClassName": { "type": "string" }, + "category": { + "title": "Category this platform belongs to. If multiple, the first one will be considered primary", + "anyOf": [ + { "$ref": "#/definitions/category" }, + { + "type": "array", + "items": { "$ref": "#/definitions/category" }, + "minItems": 2, + "uniqueItems": true + } + ] + }, "features": { "type": "object", "additionalProperties": { "$ref": "#/definitions/status" } } }, "additionalProperties": false, - "required": ["url", "logo", "features"] + "required": ["url", "category", "features"] + }, + "category": { + "enum": [ + "Web Browsers", + "Standalone Runtimes", + "Embedded Runtimes", + "Tools" + ] }, "status": { "title": "Status of this feature", @@ -80,6 +101,18 @@ "type": "object", "additionalProperties": { "$ref": "#/definitions/feature-info" } }, + "categories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "$ref": "#/definitions/category" }, + "queryKey": { "type": "string", "pattern": "^[\\w\\-\\.~]+$" }, + "default": { "const": true } + }, + "required": ["name", "queryKey"] + } + }, "browsers": { "type": "object", "additionalProperties": { "$ref": "#/definitions/browser-features" } diff --git a/images/binaryen.svg b/images/binaryen.svg index bb089ea..3b788f2 100644 --- a/images/binaryen.svg +++ b/images/binaryen.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/images/owi.png b/images/owi.png deleted file mode 100644 index 41082a1..0000000 Binary files a/images/owi.png and /dev/null differ diff --git a/images/owi.webp b/images/owi.webp new file mode 100644 index 0000000..475005c Binary files /dev/null and b/images/owi.webp differ diff --git a/js/dark-mode-toggle.min.mjs b/js/dark-mode-toggle.min.mjs index 0b3ed70..82be723 100644 --- a/js/dark-mode-toggle.min.mjs +++ b/js/dark-mode-toggle.min.mjs @@ -1,2 +1,7 @@ // @license © 2019 Google LLC. Licensed under the Apache License, Version 2.0. -const e=document;let t={};try{t=localStorage}catch(e){}const i="prefers-color-scheme";const a="media";const s="light";const r="dark";const h="system";const o=`(${i}:${r})`;const l=`(${i}:${s})`;const n="link[rel=stylesheet]";const c="style";const d="remember";const p="legend";const b="toggle";const g="switch";const m="three-way";const u="appearance";const f="permanent";const k="mode";const y="colorschemechange";const $="permanentcolorscheme";const v="all";const L="not all";const T="dark-mode-toggle";const W="https://googlechromelabs.github.io/dark-mode-toggle/demo/";const w=(e,t,i=t)=>{Object.defineProperty(e,i,{enumerable:true,get(){const e=this.getAttribute(t);return e===null?"":e},set(e){this.setAttribute(t,e)}})};const x=(e,t,i=t)=>{Object.defineProperty(e,i,{enumerable:true,get(){return this.hasAttribute(t)},set(e){if(e){this.setAttribute(t,"")}else{this.removeAttribute(t)}}})};const R=e.createElement("template");R.innerHTML=`
`;export class DarkModeToggle extends HTMLElement{static get observedAttributes(){return[k,u,f,p,s,r,d]}constructor(){super();w(this,k);w(this,u);w(this,p);w(this,s);w(this,r);w(this,h);w(this,d);x(this,f);this.t=null;this.i=null;e.addEventListener(y,e=>{this.mode=e.detail.colorScheme;this.h();this.o();this.l()});e.addEventListener($,e=>{this.permanent=e.detail.permanent;this.p.checked=this.permanent;this.l()});this.m()}m(){const t=this.attachShadow({mode:"open"});t.append(R.content.cloneNode(true));this.t=e.querySelectorAll(`${n}[${a}*=${i}][${a}*="${r}"],\n ${c}[${a}*=${i}][${a}*="${r}"]`);this.i=e.querySelectorAll(`${n}[${a}*=${i}][${a}*="${s}"], \n ${c}[${a}*=${i}][${a}*="${s}"]`);this.u=t.querySelector("[part=lightRadio]");this.k=t.querySelector("[part=lightLabel]");this.$=t.querySelector("[part=darkRadio]");this.v=t.querySelector("[part=darkLabel]");this.L=t.querySelector("[part=toggleCheckbox]");this.T=t.querySelector("[part=toggleLabel]");this.W=t.querySelector("[part=lightThreeWayRadio]");this.R=t.querySelector("[part=lightThreeWayLabel]");this.C=t.querySelector("[part=systemThreeWayRadio]");this.M=t.querySelector("[part=systemThreeWayLabel]");this.S=t.querySelector("[part=darkThreeWayRadio]");this._=t.querySelector("[part=darkThreeWayLabel]");this.A=t.querySelector("legend");this.D=t.querySelector("aside");this.p=t.querySelector("[part=permanentCheckbox]");this.O=t.querySelector("[part=permanentLabel]")}connectedCallback(){const e=matchMedia(o).media!==L;if(e){matchMedia(o).addListener(({matches:e})=>{if(this.permanent){return}this.mode=e?r:s;this.j(y,{colorScheme:this.mode})})}let i=false;try{i=t.getItem(T)}catch(e){}if(i&&[r,s].includes(i)){this.mode=i;this.p.checked=true;this.permanent=true}else if(e){this.mode=matchMedia(l).matches?s:r}if(!this.mode){this.mode=s}if(this.permanent&&!i){try{t.setItem(T,this.mode)}catch(e){}}if(!this.appearance){this.appearance=b}this.P();this.h();this.o();this.l();[this.u,this.$].forEach(e=>{e.addEventListener("change",()=>{this.mode=this.u.checked?s:r;this.o();this.l();this.j(y,{colorScheme:this.mode})})});this.L.addEventListener("change",()=>{this.mode=this.L.checked?r:s;this.h();this.l();this.j(y,{colorScheme:this.mode})});this.W.addEventListener("change",()=>{this.mode=s;this.permanent=true;this.o();this.h();this.l();this.j(y,{colorScheme:this.mode});this.j($,{permanent:this.permanent})});this.S.addEventListener("change",()=>{this.mode=r;this.permanent=true;this.o();this.h();this.l();this.j(y,{colorScheme:this.mode});this.j($,{permanent:this.permanent})});this.C.addEventListener("change",()=>{this.mode=this.H();this.permanent=false;this.o();this.h();this.l();this.j(y,{colorScheme:this.mode});this.j($,{permanent:this.permanent})});this.p.addEventListener("change",()=>{this.permanent=this.p.checked;this.l();this.j($,{permanent:this.permanent})});this.q();this.j(y,{colorScheme:this.mode});this.j($,{permanent:this.permanent})}attributeChangedCallback(e,i,a){if(e===k){const e=[s,h,r];if(!e.includes(a)){throw new RangeError(`Allowed values are: "${e.join(`", "`)}".`)}if(matchMedia("(hover:none)").matches&&this.remember){this.B()}if(this.permanent){try{t.setItem(T,this.mode)}catch(e){}}this.h();this.o();this.l();this.q()}else if(e===u){const e=[b,g,m];if(!e.includes(a)){throw new RangeError(`Allowed values are: "${e.join(`", "`)}".`)}this.P()}else if(e===f){if(this.permanent){if(this.mode){try{t.setItem(T,this.mode)}catch(e){}}}else{try{t.removeItem(T)}catch(e){}}this.p.checked=this.permanent}else if(e===p){this.A.textContent=a}else if(e===d){this.O.textContent=a}else if(e===s){this.k.textContent=a;if(this.mode===s){this.T.textContent=a}}else if(e===r){this.v.textContent=a;if(this.mode===r){this.T.textContent=a}}}H(){return matchMedia(l).matches?s:r}j(e,t){this.dispatchEvent(new CustomEvent(e,{bubbles:true,composed:true,detail:t}))}P(){this.u.hidden=this.k.hidden=this.$.hidden=this.v.hidden=this.L.hidden=this.T.hidden=this.W.hidden=this.R.hidden=this.C.hidden=this.M.hidden=this.S.hidden=this._.hidden=true;switch(this.appearance){case g:this.u.hidden=this.k.hidden=this.$.hidden=this.v.hidden=false;break;case m:this.W.hidden=this.R.hidden=this.C.hidden=this.M.hidden=this.S.hidden=this._.hidden=false;break;case b:default:this.L.hidden=this.T.hidden=false;break}}h(){if(this.mode===s){this.u.checked=true}else{this.$.checked=true}}o(){if(this.mode===s){this.T.style.setProperty(`--${T}-checkbox-icon`,`var(--${T}-light-icon,url("${W}moon.png"))`);this.T.textContent=this.light;if(!this.light){this.T.ariaLabel=r}this.L.checked=false}else{this.T.style.setProperty(`--${T}-checkbox-icon`,`var(--${T}-dark-icon,url("${W}sun.png"))`);this.T.textContent=this.dark;if(!this.dark){this.T.ariaLabel=s}this.L.checked=true}}l(){this.R.ariaLabel=s;this.M.ariaLabel=h;this._.ariaLabel=r;this.R.textContent=this.light;this.M.textContent=this.system;this._.textContent=this.dark;if(this.permanent){if(this.mode===s){this.W.checked=true}else{this.S.checked=true}}else{this.C.checked=true}}q(){if(this.mode===s){this.i.forEach(e=>{e.media=v;e.disabled=false});this.t.forEach(e=>{e.media=L;e.disabled=true})}else{this.t.forEach(e=>{e.media=v;e.disabled=false});this.i.forEach(e=>{e.media=L;e.disabled=true})}}B(){this.D.style.visibility="visible";setTimeout(()=>{this.D.style.visibility="hidden"},3e3)}}customElements.define(T,DarkModeToggle); \ No newline at end of file +const e=document;let t={};try{t=localStorage}catch(e){}const i="prefers-color-scheme";const a="media";const s="light";const r="dark";const h="system";const o=`(${i}:${r})`;const l=`(${i}:${s})`;const n="link[rel=stylesheet]";const c="style";const d="remember";const p="legend";const b="toggle";const g="switch";const m="three-way";const u="appearance";const f="permanent";const k="mode";const y="colorschemechange";const $="permanentcolorscheme";const v="all";const L="not all";const T="dark-mode-toggle";const W="https://googlechromelabs.github.io/dark-mode-toggle/demo/";const w=(e,t,i=t)=>{Object.defineProperty(e,i,{enumerable:true,get(){const e=this.getAttribute(t);return e===null?"":e},set(e){this.setAttribute(t,e)}})};const x=(e,t,i=t)=>{Object.defineProperty(e,i,{enumerable:true,get(){return this.hasAttribute(t)},set(e){if(e){this.setAttribute(t,"")}else{this.removeAttribute(t)}}})};const R=e.createElement("template");R.innerHTML=`
`;export class DarkModeToggle extends HTMLElement{static get observedAttributes(){return[k,u,f,p,s,r,d]}constructor(){super();w(this,k);w(this,u);w(this,p);w(this,s);w(this,r);w(this,h);w(this,d);x(this,f);this.t=null;this.i=null;e.addEventListener(y,e=>{this.mode=e.detail.colorScheme;this.h();this.o();this.l()});e.addEventListener($,e=>{this.permanent=e.detail.permanent;this.p.checked=this.permanent;this.l()});this.m()}m(){const t=this.attachShadow({mode:"open"});t.append(R.content.cloneNode(true));this.t=e.querySelectorAll(`${n}[${a}*=${i}][${a}*="${r}"],\n ${c}[${a}*=${i}][${a}*="${r}"]`);this.i=e.querySelectorAll(`${n}[${a}*=${i}][${a}*="${s}"], \n ${c}[${a}*=${i}][${a}*="${s}"]`);this.u=t.querySelector("[part=lightRadio]");this.k=t.querySelector("[part=lightLabel]");this.$=t.querySelector("[part=darkRadio]");this.v=t.querySelector("[part=darkLabel]");this.L=t.querySelector("[part=toggleCheckbox]");this.T=t.querySelector("[part=toggleLabel]");this.W=t.querySelector("[part=lightThreeWayRadio]");this.R=t.querySelector("[part=lightThreeWayLabel]");this.C=t.querySelector("[part=systemThreeWayRadio]");this.M=t.querySelector("[part=systemThreeWayLabel]");this.S=t.querySelector("[part=darkThreeWayRadio]");this._=t.querySelector("[part=darkThreeWayLabel]");this.A=t.querySelector("legend");this.D=t.querySelector("aside");this.p=t.querySelector("[part=permanentCheckbox]");this.O=t.querySelector("[part=permanentLabel]")}connectedCallback(){const e=matchMedia(o).media!==L;if(e){matchMedia(o).addListener(({matches:e})=>{if(this.permanent){return}this.mode=e?r:s;this.j(y,{colorScheme:this.mode})})}let i=false;try{i=t.getItem(T)}catch(e){}if(i&&[r,s].includes(i)){this.mode=i;this.p.checked=true;this.permanent=true}else if(e){this.mode=matchMedia(l).matches?s:r}if(!this.mode){this.mode=s}if(this.permanent&&!i){try{t.setItem(T,this.mode)}catch(e){}}if(!this.appearance){this.appearance=b}this.P();this.h();this.o();this.l();[this.u,this.$].forEach(e=>{e.addEventListener("change",()=>{this.mode=this.u.checked?s:r;this.o();this.l();this.j(y,{colorScheme:this.mode})})});this.L.addEventListener("change",()=>{this.mode=this.L.checked?r:s;this.h();this.l();this.j(y,{colorScheme:this.mode})});this.W.addEventListener("change",()=>{this.mode=s;this.permanent=true;this.o();this.h();this.l();this.j(y,{colorScheme:this.mode});this.j($,{permanent:this.permanent})});this.S.addEventListener("change",()=>{this.mode=r;this.permanent=true;this.o();this.h();this.l();this.j(y,{colorScheme:this.mode});this.j($,{permanent:this.permanent})});this.C.addEventListener("change",()=>{this.mode=this.H();this.permanent=false;this.o();this.h();this.l();this.j(y,{colorScheme:this.mode});this.j($,{permanent:this.permanent})});this.p.addEventListener("change",()=>{this.permanent=this.p.checked;this.l();this.j($,{permanent:this.permanent})});this.q();this.j(y,{colorScheme:this.mode});this.j($,{permanent:this.permanent})}attributeChangedCallback(e,i,a){if(e===k){const e=[s,h,r];if(!e.includes(a)){throw new RangeError(`Allowed values are: "${e.join(`", "`)}".`)}if(matchMedia("(hover:none)").matches&&this.remember){this.B()}if(this.permanent){try{t.setItem(T,this.mode)}catch(e){}}this.h();this.o();this.l();this.q()}else if(e===u){const e=[b,g,m];if(!e.includes(a)){throw new RangeError(`Allowed values are: "${e.join(`", "`)}".`)}this.P()}else if(e===f){if(this.permanent){if(this.mode){try{t.setItem(T,this.mode)}catch(e){}}}else{try{t.removeItem(T)}catch(e){}}this.p.checked=this.permanent}else if(e===p){this.A.textContent=a}else if(e===d){this.O.textContent=a}else if(e===s){this.k.textContent=a;if(this.mode===s){this.T.textContent=a}}else if(e===r){this.v.textContent=a;if(this.mode===r){this.T.textContent=a}}}H(){return matchMedia(l).matches?s:r}j(e,t){this.dispatchEvent(new CustomEvent(e,{bubbles:true,composed:true,detail:t}))}P(){this.u.hidden=this.k.hidden=this.$.hidden=this.v.hidden=this.L.hidden=this.T.hidden=this.W.hidden=this.R.hidden=this.C.hidden=this.M.hidden=this.S.hidden=this._.hidden=true;switch(this.appearance){case g:this.u.hidden=this.k.hidden=this.$.hidden=this.v.hidden=false;break;case m:this.W.hidden=this.R.hidden=this.C.hidden=this.M.hidden=this.S.hidden=this._.hidden=false;break;case b:default:this.L.hidden=this.T.hidden=false;break}}h(){if(this.mode===s){this.u.checked=true}else{this.$.checked=true}}o(){if(this.mode===s){this.T.style.setProperty(`--${T}-checkbox-icon`,`var(--${T}-light-icon,url("${W}moon.png"))`);this.T.textContent=this.light;if(!this.light){this.T.ariaLabel=r}this.L.checked=false}else{this.T.style.setProperty(`--${T}-checkbox-icon`,`var(--${T}-dark-icon,url("${W}sun.png"))`);this.T.textContent=this.dark;if(!this.dark){this.T.ariaLabel=s}this.L.checked=true}}l(){this.R.ariaLabel=s;this.M.ariaLabel=h;this._.ariaLabel=r;this.R.textContent=this.light;this.M.textContent=this.system;this._.textContent=this.dark;if(this.permanent){if(this.mode===s){this.W.checked=true}else{this.S.checked=true}}else{this.C.checked=true}}q(){if(this.mode===s){this.i.forEach(e=>{e.media=v;e.disabled=false});this.t.forEach(e=>{e.media=L;e.disabled=true})}else{this.t.forEach(e=>{e.media=v;e.disabled=false});this.i.forEach(e=>{e.media=L;e.disabled=true})}}B(){this.D.style.visibility="visible";setTimeout(()=>{this.D.style.visibility="hidden"},3e3)}}customElements.define(T,DarkModeToggle); + +// Keep the in sync with user selection. +document.addEventListener('colorschemechange', (e) => { + document.querySelectorAll('meta[name=color-scheme]').forEach(el => el.content = e.detail.colorScheme); +});