Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/bundlesize.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { promisify } from 'node:util'
import prettyBytes from 'pretty-bytes'
import fs from 'node:fs/promises'

const MAX_SIZE_MIN = '37 kB'
const MAX_SIZE_MIN = '38 kB'
const MAX_SIZE_MINGZ = '13 kB'

const FILENAME = './bundle.js'
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@
"btoa",
"crypto",
"customElements",
"CSS",
"CustomEvent",
"Event",
"fetch",
Expand Down
21 changes: 19 additions & 2 deletions src/picker/components/Picker/Picker.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { resetScrollTopIfPossible } from '../../utils/resetScrollTopIfPossible.j
import { render } from './PickerTemplate.js'
import { createState } from './reactivity.js'
import { arraysAreEqualByFunction } from '../../utils/arraysAreEqualByFunction.js'
import { contentVisibilityAction } from '../../utils/contentVisibilityAction.js'

// constants
const EMPTY_ARRAY = []
Expand All @@ -34,6 +35,7 @@ export function createRoot (shadowRoot, props) {
const abortController = new AbortController()
const abortSignal = abortController.signal
const { state, createEffect } = createState(abortSignal)
const actionContext = Object.create(null)

// initial state
assign(state, {
Expand Down Expand Up @@ -180,12 +182,13 @@ export function createRoot (shadowRoot, props) {
onSearchInput
}
const actions = {
calculateEmojiGridStyle
calculateEmojiGridStyle,
updateOnContentVisibilityChange
}

let firstRender = true
createEffect(() => {
render(shadowRoot, state, helpers, events, actions, refs, abortSignal, firstRender)
render(shadowRoot, state, helpers, events, actions, refs, abortSignal, actionContext, firstRender)
firstRender = false
})

Expand Down Expand Up @@ -377,6 +380,20 @@ export function createRoot (shadowRoot, props) {
})
}

// Re-run whenever the custom emoji in a category are shown/hidden. This is an optimization to prevent the browser
// from doing extra work for offscreen `<img>`s with `src`s, which seems to occur even with `loading="lazy"`.
function updateOnContentVisibilityChange (node) {
contentVisibilityAction(node, abortSignal, ({ skipped }) => {
node.classList.toggle('onscreen', !skipped)
/* istanbul ignore else */
if (!skipped) {
for (const img of node.querySelectorAll('img')) {
img.setAttribute('src', img.getAttribute('data-src'))
}
}
})
}

//
// Set or update the currentEmojis. Check for invalid ZWJ renderings
// (i.e. double emoji).
Expand Down
71 changes: 47 additions & 24 deletions src/picker/components/Picker/PickerTemplate.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { createFramework } from './framework.js'

export function render (container, state, helpers, events, actions, refs, abortSignal, firstRender) {
export function render (container, state, helpers, events, actions, refs, abortSignal, actionContext, firstRender) {
const { labelWithSkin, titleForEmoji, unicodeWithSkin } = helpers
const { html, map } = createFramework(state)

function emojiList (emojis, searchMode, prefix) {
function emojiList (emojis, searchMode, prefix, hideOffscreen) {
return map(emojis, (emoji, i) => {
return html`
<button role="${searchMode ? 'option' : 'menuitem'}"
Expand All @@ -16,7 +16,16 @@ export function render (container, state, helpers, events, actions, refs, abortS
${
emoji.unicode
? unicodeWithSkin(emoji, state.currentSkinTone)
: html`<img class="custom-emoji" src="${emoji.url}" alt="" loading="lazy"/>`
// Note `data-src` is due to the `hideOffscreen` optimization.
// We still use `loading="lazy"` as an extra optimization measure, and for browsers that don't
// support `content-visibility`.
: html`<img
class="custom-emoji"
alt=""
loading="lazy"
data-src="${emoji.url}"
src=${hideOffscreen ? '' : emoji.url}
/>`
}
</button>
`
Expand Down Expand Up @@ -152,7 +161,8 @@ export function render (container, state, helpers, events, actions, refs, abortS
<!--The tabindex=0 is so people can scroll up and down with the keyboard. The element has a role and a label, so I
feel it's appropriate to have the tabindex.
This on:click is a delegated click listener -->
<div data-ref="tabpanelElement" class="tabpanel ${(!state.databaseLoaded || state.message) ? 'gone' : ''}"
<div data-ref="tabpanelElement"
class="tabpanel ${(!state.databaseLoaded || state.message) ? 'gone' : ''}"
role="${state.searchMode ? 'region' : 'tabpanel'}"
aria-label="${state.searchMode ? state.i18n.searchResultsLabel : state.i18n.categories[state.currentGroup.name]}"
id="${state.searchMode ? '' : `tab-${state.currentGroup.id}`}"
Expand All @@ -162,6 +172,9 @@ export function render (container, state, helpers, events, actions, refs, abortS
<div data-action="calculateEmojiGridStyle">
${
map(state.currentEmojisWithCategories, (emojiWithCategory, i) => {
// Improve performance in custom emoji by using `content-visibility: auto` on every category
// The `--num-rows` is used in these calculations to contain the intrinsic height
const hideOffscreen = !state.searchMode && state.currentGroup.id === -1 // -1 means custom emoji
return html`
<!-- wrapper div so there's one top level element for this loop -->
<div>
Expand All @@ -187,18 +200,15 @@ export function render (container, state, helpers, events, actions, refs, abortS
)
}
</div>
<!--
Improve performance in custom emoji by using \`content-visibility: auto\` on every category
The \`--num-rows\` is also used in these calculations to contain the intrinsic height
-->
<div class="emoji-menu ${!state.searchMode && emojiWithCategory.category ? 'hide-offscreen' : ''}"
<div class="emoji-menu ${hideOffscreen ? 'hide-offscreen' : ''}"
style=${`--num-rows: ${Math.ceil(emojiWithCategory.emojis.length / state.numColumns)}`}
data-action="${hideOffscreen ? 'updateOnContentVisibilityChange' : ''}"
role="${state.searchMode ? 'listbox' : 'menu'}"
aria-labelledby="menu-label-${i}"
id=${state.searchMode ? 'search-results' : ''}
>
${
emojiList(emojiWithCategory.emojis, state.searchMode, /* prefix */ 'emo')
emojiList(emojiWithCategory.emojis, state.searchMode, /* prefix */ 'emo', hideOffscreen)
}
</div>
</div>
Expand All @@ -213,7 +223,8 @@ export function render (container, state, helpers, events, actions, refs, abortS
aria-label="${state.i18n.favoritesLabel}"
data-on-click="onEmojiClick">
${
emojiList(state.currentFavorites, /* searchMode */ false, /* prefix */ 'fav')
// favorites bar does not participate in the hideOffscreen optimization; it's always visible
emojiList(state.currentFavorites, /* searchMode */ false, /* prefix */ 'fav', /* hideOffscreen */ false)
}
</div>
<!-- This serves as a baseline emoji for measuring against and determining emoji support -->
Expand All @@ -226,17 +237,17 @@ export function render (container, state, helpers, events, actions, refs, abortS

const rootDom = section()

// helper for traversing the dom, finding elements by an attribute, and getting the attribute value
const forElementWithAttribute = (attributeName, callback) => {
for (const element of container.querySelectorAll(`[${attributeName}]`)) {
callback(element, element.getAttribute(attributeName))
}
}

if (firstRender) { // not a re-render
container.appendChild(rootDom)

// we only bind events/refs/actions once - there is no need to find them again given this component structure

// helper for traversing the dom, finding elements by an attribute, and getting the attribute value
const forElementWithAttribute = (attributeName, callback) => {
for (const element of container.querySelectorAll(`[${attributeName}]`)) {
callback(element, element.getAttribute(attributeName))
}
}
// we only bind events/refs once - there is no need to find them again given this component structure

// bind events
for (const eventName of ['click', 'focusout', 'input', 'keydown', 'keyup']) {
Expand All @@ -250,14 +261,26 @@ export function render (container, state, helpers, events, actions, refs, abortS
refs[ref] = element
})

// set up actions
forElementWithAttribute('data-action', (element, action) => {
actions[action](element)
})

// destroy/abort logic
abortSignal.addEventListener('abort', () => {
container.removeChild(rootDom)
})
}

// set up actions
forElementWithAttribute('data-action', (element, action) => {
if (!action) {
return // `data-action` may be set to the empty string, meaning don't do anything
}
let boundActions = actionContext[action]
if (!boundActions) {
boundActions = actionContext[action] = new WeakSet()
}

// avoid applying the same action to the same element multiple times
if (!boundActions.has(element)) {
boundActions.add(element)
actions[action](element)
}
})
}
13 changes: 12 additions & 1 deletion src/picker/styles/picker.scss
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ $skintoneZIndex3: 3;
calc(var(--num-columns) * var(--total-emoji-size))
// height
calc(var(--num-rows) * var(--total-emoji-size));

// Optimization - when images for custom emoji are offscreen, set `visibility:visible` so that we don't see a flash
// of broken images while we change the `data-src` to a `src` when the `content-visibility` auto state changes.
// Note that a11y-wise, setting `hidden` here is fine because the `<img>`s are already `alt=''` meaning
// they are marked as purely decorative.
.custom-emoji {
visibility: hidden;
}
&.onscreen .custom-emoji {
visibility: visible;
}
}
}

Expand Down Expand Up @@ -222,4 +233,4 @@ input.search {

.message {
padding: var(--emoji-padding);
}
}
13 changes: 13 additions & 0 deletions src/picker/utils/contentVisibilityAction.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/* istanbul ignore next */
const SUPPORTS_CONTENT_VISIBILITY = import.meta.env.MODE !== 'test' && CSS.supports('content-visibility', 'auto')

export function contentVisibilityAction (node, signal, listener) {
/* istanbul ignore if */
if (SUPPORTS_CONTENT_VISIBILITY) {
node.addEventListener('contentvisibilityautostatechange', listener, { signal })
} else {
// If content visibility is unsupported, then just treat every element as always visible.
// This browser will just not get the optimization
listener({ skipped: false })
}
}
Loading