197 lines
8.6 KiB
HTML
197 lines
8.6 KiB
HTML
<svelte:options customElement={null} /><section
|
|
class="picker"
|
|
aria-label={i18n.regionLabel}
|
|
style={pickerStyle}
|
|
bind:this={rootElement}>
|
|
<!-- using a spacer div because this allows us to cover up the skintone picker animation -->
|
|
<div class="pad-top"></div>
|
|
<div class="search-row">
|
|
<div class="search-wrapper">
|
|
<!-- no need for aria-haspopup=listbox, it's the default for role=combobox
|
|
https://www.w3.org/TR/2017/NOTE-wai-aria-practices-1.1-20171214/examples/combobox/aria1.1pattern/listbox-combo.html
|
|
-->
|
|
<input
|
|
id="search"
|
|
class="search"
|
|
type="search"
|
|
role="combobox"
|
|
enterkeyhint="search"
|
|
placeholder={i18n.searchLabel}
|
|
autocapitalize="none"
|
|
autocomplete="off"
|
|
spellcheck="true"
|
|
aria-expanded={!!(searchMode && currentEmojis.length)}
|
|
aria-controls="search-results"
|
|
aria-describedby="search-description"
|
|
aria-autocomplete="list"
|
|
aria-activedescendant={activeSearchItemId ? `emo-${activeSearchItemId}` : ''}
|
|
bind:value={rawSearchText}
|
|
on:keydown={onSearchKeydown}
|
|
>
|
|
<label class="sr-only" for="search">{i18n.searchLabel}</label>
|
|
<span id="search-description" class="sr-only">{i18n.searchDescription}</span>
|
|
</div>
|
|
<!-- For the pattern used for the skintone dropdown, see:
|
|
https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/
|
|
The one case where we deviate from the example is that we move focus from the button to the
|
|
listbox. (The example uses a combobox, so it's not exactly the same.) This was tested in NVDA and VoiceOver.
|
|
Note that Svelte's a11y checker will warn if the listbox does not have a tabindex.
|
|
https://github.com/sveltejs/svelte/blob/3bc791b/site/content/docs/06-accessibility-warnings.md#a11y-aria-activedescendant-has-tabindex
|
|
-->
|
|
<div class="skintone-button-wrapper {skinTonePickerExpandedAfterAnimation ? 'expanded' : ''}">
|
|
<button id="skintone-button"
|
|
class="emoji {skinTonePickerExpanded ? 'hide-focus' : ''}"
|
|
aria-label={skinToneButtonLabel}
|
|
title={skinToneButtonLabel}
|
|
aria-describedby="skintone-description"
|
|
aria-haspopup="listbox"
|
|
aria-expanded={skinTonePickerExpanded}
|
|
aria-controls="skintone-list"
|
|
on:click={onClickSkinToneButton}>
|
|
{skinToneButtonText}
|
|
</button>
|
|
</div>
|
|
<span id="skintone-description" class="sr-only">{i18n.skinToneDescription}</span>
|
|
<div id="skintone-list"
|
|
class="skintone-list hide-focus {skinTonePickerExpanded ? '' : 'hidden no-animate'}"
|
|
style="transform:translateY({ skinTonePickerExpanded ? 0 : 'calc(-1 * var(--num-skintones) * var(--total-emoji-size))'})"
|
|
role="listbox"
|
|
aria-label={i18n.skinTonesLabel}
|
|
aria-activedescendant="skintone-{activeSkinTone}"
|
|
aria-hidden={!skinTonePickerExpanded}
|
|
tabindex="-1"
|
|
on:focusout={onSkinToneOptionsFocusOut}
|
|
on:click={onSkinToneOptionsClick}
|
|
on:keydown={onSkinToneOptionsKeydown}
|
|
on:keyup={onSkinToneOptionsKeyup}
|
|
bind:this={skinToneDropdown}>
|
|
{#each skinTones as skinTone, i (skinTone)}
|
|
<div id="skintone-{i}"
|
|
class="emoji {i === activeSkinTone ? 'active' : ''}"
|
|
aria-selected={i === activeSkinTone}
|
|
role="option"
|
|
title={i18n.skinTones[i]}
|
|
aria-label={i18n.skinTones[i]}>
|
|
{skinTone}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
</div>
|
|
<!-- this is interactive because of keydown; it doesn't really need focus -->
|
|
<!-- svelte-ignore a11y-interactive-supports-focus -->
|
|
<div class="nav"
|
|
role="tablist"
|
|
style="grid-template-columns: repeat({groups.length}, 1fr)"
|
|
aria-label={i18n.categoriesLabel}
|
|
on:keydown={onNavKeydown}>
|
|
{#each groups as group (group.id)}
|
|
<button role="tab"
|
|
class="nav-button"
|
|
aria-controls="tab-{group.id}"
|
|
aria-label={i18n.categories[group.name]}
|
|
aria-selected={!searchMode && currentGroup.id === group.id}
|
|
title={i18n.categories[group.name]}
|
|
on:click={() => onNavClick(group)}>
|
|
<div class="nav-emoji emoji">
|
|
{group.emoji}
|
|
</div>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<div class="indicator-wrapper">
|
|
<div class="indicator"
|
|
style="transform: translateX({(isRtl ? -1 : 1) * currentGroupIndex * 100}%)">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="message {message ? '' : 'gone'}"
|
|
role="alert"
|
|
aria-live="polite">
|
|
{message}
|
|
</div>
|
|
|
|
<!-- 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. -->
|
|
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
|
|
<!-- This on:click is a delegated click listener -->
|
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
<div class="tabpanel {(!databaseLoaded || message) ? 'gone': ''}"
|
|
role={searchMode ? 'region' : 'tabpanel'}
|
|
aria-label={searchMode ? i18n.searchResultsLabel : i18n.categories[currentGroup.name]}
|
|
id={searchMode ? '' : `tab-${currentGroup.id}`}
|
|
tabindex="0"
|
|
on:click={onEmojiClick}
|
|
bind:this={tabpanelElement}
|
|
>
|
|
<div use:calculateEmojiGridStyle>
|
|
{#each currentEmojisWithCategories as emojiWithCategory, i (emojiWithCategory.category)}
|
|
<div
|
|
id="menu-label-{i}"
|
|
class="category {currentEmojisWithCategories.length === 1 && currentEmojisWithCategories[0].category === '' ? 'gone' : ''}"
|
|
aria-hidden="true">
|
|
<!-- This logic is a bit complicated in order to avoid a flash of the word "Custom" while switching
|
|
from a tabpanel with custom emoji to a regular group. I.e. we don't want it to suddenly flash
|
|
from "Custom" to "Smileys and emoticons" when you click the second nav button. The easiest
|
|
way to repro this is to add an artificial delay to the IndexedDB operations. -->
|
|
{
|
|
searchMode ?
|
|
i18n.searchResultsLabel : (
|
|
emojiWithCategory.category ?
|
|
emojiWithCategory.category : (
|
|
currentEmojisWithCategories.length > 1 ?
|
|
i18n.categories.custom :
|
|
i18n.categories[currentGroup.name]
|
|
)
|
|
)
|
|
}
|
|
</div>
|
|
<div class="emoji-menu"
|
|
role={searchMode ? 'listbox' : 'menu'}
|
|
aria-labelledby="menu-label-{i}"
|
|
id={searchMode ? 'search-results' : ''}>
|
|
{#each emojiWithCategory.emojis as emoji, i (emoji.id)}
|
|
<button role={searchMode ? 'option' : 'menuitem'}
|
|
aria-selected={searchMode ? i == activeSearchItem : ''}
|
|
aria-label={labelWithSkin(emoji, currentSkinTone)}
|
|
title={titleForEmoji(emoji)}
|
|
class="emoji {searchMode && i === activeSearchItem ? 'active' : ''}"
|
|
id="emo-{emoji.id}">
|
|
{#if emoji.unicode}
|
|
{unicodeWithSkin(emoji, currentSkinTone)}
|
|
{:else}
|
|
<img class="custom-emoji" src={emoji.url} alt="" loading="lazy" />
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
<!-- This on:click is a delegated click listener -->
|
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
<!-- svelte-ignore a11y-interactive-supports-focus -->
|
|
<div class="favorites emoji-menu {message ? 'gone': ''}"
|
|
role="menu"
|
|
aria-label={i18n.favoritesLabel}
|
|
style="padding-inline-end: {scrollbarWidth}px"
|
|
on:click={onEmojiClick}>
|
|
<!-- The reason the emoji logic below is largely duplicated is because it turns out we get a smaller
|
|
bundle size from just repeating it twice, rather than creating a second Svelte component. -->
|
|
{#each currentFavorites as emoji, i (emoji.id)}
|
|
<button role="menuitem"
|
|
aria-label={labelWithSkin(emoji, currentSkinTone)}
|
|
title={titleForEmoji(emoji)}
|
|
class="emoji"
|
|
id="fav-{emoji.id}">
|
|
{#if emoji.unicode}
|
|
{unicodeWithSkin(emoji, currentSkinTone)}
|
|
{:else}
|
|
<img class="custom-emoji" src={emoji.url} alt="" loading="lazy" />
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<!-- This serves as a baseline emoji for measuring against and determining emoji support -->
|
|
<button aria-hidden="true" tabindex="-1" class="abs-pos hidden emoji" bind:this={baselineEmoji}>😀</button>
|
|
</section> |