1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-10-10 21:21:56 +00:00
* Fixed: Unable to see in-line chat action context menus in theater mode.
* Changed: Add a new socket server to the list to take pressure off the others.
* API Added: `debounce` method in `FrankerFaceZ.utilities.object`.
* API Added: `<autocomplete>` Vue component for implementing text fields with auto-completion.
* API Changed: Update localized Vue strings immediately when the i18n debug transformation changes.
* API Changed: `<icon-picker />` now has a closed and open state. It doesn't always show the drawer of icons.
* API Changed: Include the `vue-clickaway` mixin in everything.
This commit is contained in:
SirStendec 2019-06-09 19:48:26 -04:00
parent aa25bff498
commit 21ee6fcfb7
23 changed files with 667 additions and 100 deletions

View file

@ -0,0 +1,381 @@
<template>
<div class="ffz--autocomplete tw-relative">
<div class="tw-search-input" data-a-target="dropdown-search-input">
<label v-if="placeholder" :for="'ffz-autocomplete$' + id" class="tw-hide-accessible">{{ placeholder }}</label>
<div class="tw-relative">
<div v-if="hasIcon" class="tw-absolute tw-align-items-center tw-c-text-alt-2 tw-flex tw-full-height tw-input__icon tw-justify-content-center tw-left-0 tw-top-0 tw-z-default">
<figure :class="icon" />
</div>
<input
:id="'ffz-autocomplete$' + id"
:placeholder="placeholder"
:class="[hasIcon ? 'tw-pd-l-3' : 'tw-pd-l-1']"
v-model="search"
type="search"
class="tw-block tw-border-radius-medium tw-font-size-6 tw-full-width tw-input tw-pd-r-1 tw-pd-y-05"
autocapitalize="off"
autocorrect="off"
autocomplete="off"
spellcheck="false"
@focus="onFocus"
@blur="onBlur"
@input="onChange"
@keydown.escape="onEscape"
@keydown.down="onDown"
@keydown.up="onUp"
@keydown.enter="onEnter"
@keydown.home="onHome"
@keydown.end="onEnd"
>
</div>
</div>
<balloon v-if="open" :dir="direction" color="background-base">
<div ref="list" tabindex="-1">
<simplebar classes="scrollable-area--suppress-scroll-x">
<div
v-if="! hasItems && ! loading"
class="tw-align-center tw-c-text-alt-2 tw-pd-y-05 tw-pd-x-1"
>
{{ t('autocomplete.empty', 'There are no results.') }}
</div>
<div v-else-if="! hasItems && loading" class="tw-align-center tw-c-text-alt-2 tw-pd-05">
<h3 class="ffz-i-zreknarf loading" />
</div>
<button
v-for="(item, idx) of filteredItems"
:key="has(item, 'id') ? item.id : idx"
:id="'ffz-autocomplete-item-' + id + '-' + idx"
:class="{'tw-interactable--hover' : idx === index}"
class="tw-block tw-full-width tw-interactable tw-interactable--inverted tw-interactive"
tabindex="-1"
data-selectable="true"
@mouseenter="index = idx"
@click="selectItem(item)"
>
<slot :item="item">
<div class="tw-pd-x-1 tw-pd-y-05">
<span :title="item.title">{{ item.displayName || item.label || item.name }}</span>
</div>
</slot>
</button>
</simplebar>
</div>
</balloon>
</div>
</template>
<script>
import {has as objectHas, debounce} from 'utilities/object';
let last_id = 0;
export default {
props: {
items: {
type: [Array, Function],
required: false,
default: () => []
},
icon: {
type: String,
required: false,
default: ''
},
placeholder: {
type: String,
required: false,
default: ''
},
value: {
type: String,
required: false,
default: ''
},
suggestOnFocus: {
type: Boolean,
required: false,
default: false
},
suggestWhenEmpty: {
type: Boolean,
required: false,
default: false
},
escapeToClear: {
type: Boolean,
required: false,
default: true
},
clearOnSelect: {
type: Boolean,
required: false,
default: false
},
cacheDuration: {
type: Number,
required: false,
default: 5000
},
direction: {
type: String,
required: false,
default: 'down'
}
},
data() {
const is_fn = typeof this.items === 'function';
return {
id: last_id++,
search: this.value,
focused: false,
open: false,
index: 0,
cachedFor: null,
cachedAt: 0,
cachedItems: is_fn ? [] : this.items,
async: is_fn,
loading: false,
errored: false
}
},
computed: {
hasIcon() {
return this.icon && this.icon.length > 0
},
hasItems() {
return this.filteredItems && this.filteredItems.length > 0
},
filteredItems() {
if ( this.errored )
return null;
if ( ! this.search || ! this.search.length )
return this.cachedItems;
const needle = this.search.toLowerCase();
return this.cachedItems.filter(item => {
if ( typeof item.displayName === 'string' && item.displayName.toLowerCase().includes(needle) )
return true;
if ( typeof item.label === 'string' && item.label.toLowerCase().includes(needle) )
return true;
if ( typeof item.name === 'string' && item.name.toLowerCase().includes(needle) )
return true;
return typeof item.value === 'string' && item.value.toLowerCase().includes(needle);
})
}
},
watch: {
items() {
const is_fn = typeof this.items === 'function';
this.cachedItems = is_fn ? [] : this.items;
this.async = is_fn;
this.loading = false;
this.errored = false;
if ( this.open )
this.updateCache();
}
},
created() {
this.maybeClose = debounce(this.maybeClose, 25);
this.updateCache = debounce(this.updateCache, 250, 2);
},
methods: {
has(thing, key) {
return objectHas(thing, key)
},
updateCache() {
if ( ! this.async )
return;
if ( this.search === this.cachedFor && (Date.now() - this.cachedAt) < this.cacheDuration )
return;
this.loading = false;
this.errored = false;
this.cachedFor = this.search;
this.cachedAt = Date.now();
let result = null;
try {
result = this.items(this.search);
} catch(err) {
console.error(err);
}
if ( result instanceof Promise ) {
this.loading = true;
result.then(items => {
this.loading = false;
this.cachedItems = items;
}).catch(err => {
console.error(err);
this.loading = false;
this.errored = true;
this.cachedItems = [];
});
} else if ( Array.isArray(result) )
this.cachedItems = result;
else {
this.errored = true;
this.cachedItems = [];
}
},
onFocus() {
this.focused = true;
this.$emit('focus');
if ( this.open || ! this.suggestOnFocus )
return;
if ( ! this.suggestWhenEmpty && (! this.search || ! this.search.length) )
return;
this.updateCache();
if ( ! this.open ) {
this.open = true;
this.index = -1;
}
},
onBlur() {
this.focused = false;
this.$emit('blur');
this.maybeClose();
},
maybeClose() {
if ( ! this.focused )
this.open = false;
},
onChange() {
this.$emit('input', this.search);
if ( (! this.search || ! this.search.length) && ! this.suggestWhenEmpty ) {
this.loading = false;
this.open = false;
return;
}
this.updateCache();
if ( ! this.open ) {
this.open = true;
this.index = -1;
}
},
onEscape(event) {
if ( this.open || ! this.escapeToClear )
event.preventDefault();
this.open = false;
},
onHome(event) {
if ( ! this.open )
return;
event.preventDefault();
if ( this.filteredItems )
this.index = 0;
this.scrollToItem();
},
onEnd(event) {
if ( ! this.open )
return;
event.preventDefault();
if ( this.filteredItems )
this.index = this.filteredItems.length - 1;
this.scrollToItem();
},
onUp(event) {
if ( ! this.open )
return;
this.index--;
if ( ! this.filteredItems )
this.index = -1;
else if ( this.index < 0 )
this.index = 0;
this.scrollToItem();
event.preventDefault();
},
onDown(event) {
if ( ! this.open )
return;
this.index++;
if ( ! this.filteredItems )
this.index = -1;
else if ( this.index >= this.filteredItems.length )
this.index = this.filteredItems.length - 1;
this.scrollToItem();
event.preventDefault();
},
scrollToItem() {
const root = this.$refs.list,
element = root && root.querySelector(`#ffz-autocomplete-item-${this.id}-${this.index}`);
if ( element )
element.scrollIntoViewIfNeeded();
},
onEnter(event) {
if ( ! this.open )
return;
event.preventDefault();
const item = this.filteredItems[this.index];
if ( item )
this.selectItem(item);
},
selectItem(item) {
if ( this.clearOnSelect ) {
this.search = '';
if ( ! this.suggestWhenEmpty )
this.open = false;
} else {
this.search = item.label || item.name || item.value;
this.open = false;
}
this.$emit('input', this.search);
this.$emit('selected', objectHas(item, 'value') ? item.value : objectHas(item, 'name') ? item.name : (item.label || item.displayName));
}
}
}
</script>

View file

@ -21,7 +21,7 @@ export default {
props: {
color: {
type: String,
default: 'background'
default: 'background-base'
},
size: String,

View file

@ -1,51 +1,72 @@
<template lang="html">
<div class="ffz--icon-picker">
<div class="tw-full-width">
<div class="tw-search-input">
<label :for="'icon-search$' + id" class="tw-hide-accessible">
{{ t('setting.icon.search', 'Search for Icon') }}
</label>
<div class="tw-relative tw-mg-t-05">
<div class="tw-absolute tw-align-items-center tw-c-text-alt-2 tw-flex tw-full-height tw-input__icon tw-justify-content-center tw-left-0 tw-top-0 tw-z-default">
<figure class="ffz-i-search" />
</div>
<input
:id="'icon-search$' + id"
:placeholder="t('setting.actions.icon.search', 'Search for Icon')"
v-model="search"
type="search"
class="tw-block tw-border-radius-medium tw-font-size-6 tw-full-width tw-input tw-pd-l-3 tw-pd-r-1 tw-pd-y-05"
autocapitalize="off"
autocorrect="off"
autocomplete="off"
>
<div v-on-clickaway="close" class="ffz--icon-picker tw-relative">
<div class="tw-search-input tw-full-width">
<label v-if="open" :for="'icon-search$' + id" class="tw-hide-accessible">{{ t('setting.icon.search', 'Search for Icon') }}</label>
<div class="tw-relative">
<div class="tw-absolute tw-align-items-center tw-c-text-alt-2 tw-flex tw-full-height tw-input__icon tw-justify-content-center tw-left-0 tw-top-0 tw-z-default">
<figure :class="[(isOpen || ! val || ! val.length) ? 'ffz-i-search' : val]" />
</div>
<input
ref="input"
:id="'icon-search$' + id"
:placeholder="('setting.icon.search', 'Search for Icon')"
:value="isOpen ? search : val"
:class="[clearable ? 'tw-pd-r-5' : 'tw-pd-r-1']"
type="text"
class="tw-block tw-border-radius-medium tw-font-size-6 tw-full-width tw-input tw-pd-l-3 tw-pd-y-05"
autocapitalize="off"
autocorrect="off"
autocomplete="off"
spellcheck="false"
@input="update"
@focus="onFocus"
@blur="onBlur"
@keydown.escape="open = false"
>
<button
v-if="clearable"
class="tw-absolute tw-right-0 tw-top-0 tw-button tw-button--text tw-tooltip-wrapper"
@click="change('', false)"
@keydown.escape="open = false"
@focus="onFocus(false)"
@blur="onBlur"
>
<span class="tw-button__text ffz-i-trash" />
<div class="tw-tooltip tw-tooltip--up tw-tooltip--align-right">
{{ t('setting.icon.clear', 'Clear') }}
</div>
</button>
</div>
<simplebar classes="tw-c-background-alt-2 tw-border-l tw-border-r tw-border-b ffz--icon-picker__list tw-mg-b-05">
<div v-if="visible.length" role="radiogroup" class="tw-pd-1 tw-flex tw-flex-wrap tw-justify-content-between" >
<div
v-for="i of visible"
:key="i[0]"
:aria-checked="value === i[0]"
:class="{'tw-interactable--selected': value === i[0]}"
:data-title="i[1]"
class="ffz-tooltip ffz-icon tw-interactable tw-interactable--inverted"
role="radio"
tabindex="0"
@keydown.space.stop.prevent=""
@keyup.space="change(i[0])"
@keyup.enter="change(i[0])"
@click="change(i[0])"
>
<figure :class="`tw-mg-y-05 tw-mg-x-1 ${i[0]}`" />
</div>
</div>
<div v-else class="tw-align-center tw-pd-1 tw-c-text-alt-2">
{{ t('setting.actions.empty-search', 'no results') }}
</div>
</simplebar>
</div>
<balloon v-if="open" :dir="direction" color="background-base">
<div ref="list">
<simplebar classes="scrollable-area--suppress-scroll-x ffz--icon-picker__list">
<div v-if="visible.length" role="radiogroup" class="tw-pd-1 tw-flex tw-flex-wrap tw-justify-content-between" >
<div
v-for="i of visible"
:key="i[0]"
:aria-checked="val === i[0]"
:class="{'tw-interactable--selected': val === i[0]}"
:data-title="i[1]"
class="ffz-tooltip ffz-icon tw-interactable tw-interactable--inverted"
role="radio"
tabindex="0"
@keydown.space.stop.prevent=""
@keyup.space="change(i[0])"
@keyup.enter="change(i[0])"
@click="change(i[0])"
@focus="onFocus(false)"
@blur="onBlur"
>
<figure :class="`tw-mg-y-05 tw-mg-x-1 ${i[0]}`" />
</div>
</div>
<div v-else class="tw-align-center tw-pd-1 tw-c-text-alt-2">
{{ t('setting.actions.empty-search', 'no results') }}
</div>
</simplebar>
</div>
</balloon>
</div>
</template>
@ -53,8 +74,9 @@
let id = 0;
import {escape_regex, deep_copy} from 'utilities/object';
import {escape_regex, deep_copy, debounce} from 'utilities/object';
import {load, ICONS as FA_ICONS, ALIASES as FA_ALIASES} from 'utilities/font-awesome';
import { maybeLoad } from '../utilities/font-awesome';
const FFZ_ICONS = [
'cancel',
@ -107,6 +129,7 @@ const FFZ_ICONS = [
'cw',
'up-dir',
'up-big',
'play',
'link-ext',
'twitter',
'github',
@ -140,11 +163,30 @@ const ICONS = FFZ_ICONS
.concat(FA_ICONS.filter(x => ! FFZ_ICONS.includes(x)).map(x => [`ffz-fa fa-${x}`, FA_ALIASES[x] ? FA_ALIASES[x].join(' ') : x]));
export default {
props: ['value'],
props: {
value: String,
alwaysOpen: {
type: Boolean,
required: false,
default: false
},
clearable: {
type: Boolean,
required: false,
default: false
},
direction: {
type: String,
required: false,
default: 'down'
}
},
data() {
return {
id: id++,
open: false,
val: this.value,
search: '',
icons: deep_copy(ICONS)
}
@ -159,25 +201,81 @@ export default {
reg = new RegExp('(?:^|-| )' + escape_regex(search), 'i');
return this.icons.filter(x => reg.test(x[1]));
},
isOpen() {
return this.alwaysOpen || this.open
}
},
mounted() {
load();
watch: {
value() {
this.val = this.value;
},
this.$nextTick(() => {
if ( this.value ) {
const el = this.$el.querySelector('.tw-interactable--selected');
if ( el )
el.scrollIntoViewIfNeeded();
isOpen() {
if ( ! this.isOpen ) {
requestAnimationFrame(() => {
const ffz = FrankerFaceZ.get();
if ( ffz )
ffz.emit('tooltips:cleanup');
});
return;
}
});
load();
this.$nextTick(() => {
if ( this.val ) {
const root = this.$refs.list,
el = root && root.querySelector('.tw-interactable--selected');
if ( el )
el.scrollIntoViewIfNeeded();
}
});
}
},
created() {
this.maybeClose = debounce(this.maybeClose, 10);
},
mounted() {
maybeLoad(this.val);
},
methods: {
change(val) {
this.value = val;
this.$emit('input', this.value);
update() {
if ( this.open )
this.search = this.$refs.input.value;
},
close() {
this.open = false;
},
change(val, close = true) {
this.val = val;
this.$emit('input', this.val);
if ( close )
this.open = false;
},
onFocus(open = true) {
this.focused = true;
if ( open )
this.open = true;
},
onBlur() {
this.focused = false;
this.maybeClose();
},
maybeClose() {
if ( ! this.focused )
this.open = false;
}
}
}