Unverified Commit 481aa74c authored by Kyle Keating's avatar Kyle Keating Committed by GitHub

Create TagEditor component (#1558)

Add new preact tag editor component
parent 418f2d85
'use strict'; 'use strict';
// @ngInject const { createElement } = require('preact');
function TagEditorController(tags) { const propTypes = require('prop-types');
this.onTagsChanged = function() { const { useMemo, useRef, useState } = require('preact/hooks');
tags.store(this.tagList);
const newTags = this.tagList.map(function(item) { const { withServices } = require('../util/service-context');
return item.text; const SvgIcon = require('./svg-icon');
// Global counter used to create a unique id for each instance of a TagEditor
let datalistIdCounter = 0;
/**
* Component to edit annotation's tags.
*/
function TagEditor({ onEditTags, tags: tagsService, tagList }) {
const inputEl = useRef(null);
const [showSuggestions, setShowSuggestions] = useState(false);
const [datalistId] = useState(() => {
++datalistIdCounter;
return `tag-editor-datalist-${datalistIdCounter}`;
});
// List of suggestions returned from the tagsService
const suggestions = useMemo(() => {
// Remove any repeated suggestions that are already tags.
const removeDuplicates = (suggestions, tags) => {
const suggestionsSet = [];
suggestions.forEach(suggestion => {
if (tags.indexOf(suggestion) < 0) {
suggestionsSet.push(suggestion);
}
}); });
this.onEditTags({ tags: newTags }); return suggestionsSet.sort();
}; };
// Call filter with an empty string to return all suggestions
return removeDuplicates(tagsService.filter(''), tagList);
}, [tagsService, tagList]);
this.autocomplete = function(query) { /**
return Promise.resolve(tags.filter(query)); * Handle changes to this annotation's tags
*/
const updateTags = tagList => {
// update suggested tags list via service
tagsService.store(tagList.map(tag => ({ text: tag })));
onEditTags({ tags: tagList });
}; };
this.$onChanges = function(changes) { /**
if (changes.tags) { * Remove a tag from this annotation.
this.tagList = changes.tags.currentValue.map(function(tag) { *
return { text: tag }; * @param {string} tag
}); */
const removeTag = tag => {
const newTagList = [...tagList]; // make a copy
const index = newTagList.indexOf(tag);
newTagList.splice(index, 1);
updateTags(newTagList);
};
/**
* Adds a tag to the annotation equal to the value of the input field
* and then clears out the suggestions list and the input field.
*/
const addTag = () => {
const value = inputEl.current.value.trim();
if (value.length === 0) {
// don't add an empty tag
return;
}
if (tagList.indexOf(value) >= 0) {
// don't add duplicate tag
return;
} }
updateTags([...tagList, value]);
setShowSuggestions(false);
inputEl.current.value = '';
inputEl.current.focus();
}; };
/**
* If the user pressed enter or comma while focused on
* the <input>, then add a new tag.
*/
const handleKeyPress = e => {
if (e.key === 'Enter' || e.key === ',') {
addTag();
// preventDefault stops the delimiter from being
// added to the input field
e.preventDefault();
}
};
const handleOnInput = e => {
if (e.inputType === 'insertText') {
// Show the suggestions if the user types something into the field
setShowSuggestions(true);
} else if (
e.inputType === undefined ||
e.inputType === 'insertReplacementText'
) {
// nb. Chrome / Safari reports undefined and Firefox reports 'insertReplacementText'
// for the inputTyp value when clicking on an element in the datalist.
//
// There are two ways to arrive here, either click an item with the mouse
// or use keyboard navigation and press 'Enter'.
//
// If the input value typed already exactly matches the option selected
// then this event won't fire and a user would have to press 'Enter' a second
// time to trigger the handleKeyPress callback above to add the tag.
// Bug: https://github.com/hypothesis/client/issues/1604
addTag();
} else if (inputEl.current.value.length === 0) {
// If the user deleted input, hide suggestions. This has
// no effect in Safari and the list will stay open.
setShowSuggestions(false);
}
};
const handleKeyUp = e => {
// Safari on macOS and iOS have an issue where pressing "Enter" in an
// input when its value exactly matches a suggestion in the associated <datalist>
// does not generate a "keypress" event. Therefore we catch the subsequent
// "keyup" event instead.
if (e.key === 'Enter') {
// nb. `addTag` will do nothing if the "keypress" event was already handled.
addTag();
}
};
const suggestionsList = () => {
return (
<datalist
id={datalistId}
className="tag-editor__suggestions"
aria-label="Annotation suggestions"
>
{showSuggestions &&
suggestions.map(suggestion => (
<option key={suggestion} value={suggestion} />
))}
</datalist>
);
};
return (
<section className="tag-editor">
<ul
className="tag-editor__tag-list"
aria-label="Suggested tags for annotation"
>
{tagList.map(tag => {
return (
<li
key={`${tag}`}
className="tag-editor__tag-item"
aria-label={`Tag: ${tag}`}
>
<span className="tag-editor__edit">{tag}</span>
<button
onClick={() => {
removeTag(tag);
}}
title={`Remove Tag: ${tag}`}
className="tag-editor__delete"
>
<SvgIcon name="cancel" />
</button>
</li>
);
})}
</ul>
<input
list={datalistId}
onInput={handleOnInput}
onKeyPress={handleKeyPress}
onKeyUp={handleKeyUp}
ref={inputEl}
placeholder="Add tags..."
className="tag-editor__input"
type="text"
/>
{suggestionsList()}
</section>
);
} }
module.exports = { /**
controller: TagEditorController, * @typedef Tag
controllerAs: 'vm', * @param tag {string} - The tag text
bindings: { */
tags: '<',
onEditTags: '&', TagEditor.propTypes = {
}, /**
template: require('../templates/tag-editor.html'), * Callback that saves the tag list.
*
* @param {Array<Tag>} - Array of tags to save
*/
onEditTags: propTypes.func.isRequired,
/* The list of editable tags as strings. */
tagList: propTypes.array.isRequired,
/** Services */
tags: propTypes.object.isRequired,
serviceUrl: propTypes.func.isRequired,
}; };
TagEditor.injectedProps = ['serviceUrl', 'tags'];
module.exports = withServices(TagEditor);
...@@ -60,10 +60,10 @@ function TagList({ annotation, serviceUrl, settings, tags }) { ...@@ -60,10 +60,10 @@ function TagList({ annotation, serviceUrl, settings, tags }) {
TagList.propTypes = { TagList.propTypes = {
/* Annotation that owns the tags. */ /* Annotation that owns the tags. */
annotation: propTypes.object, annotation: propTypes.object.isRequired,
/* List of tags as strings. */ /* List of tags as strings. */
tags: propTypes.array, tags: propTypes.array.isRequired,
/** Services */ /** Services */
serviceUrl: propTypes.func, serviceUrl: propTypes.func,
......
...@@ -194,7 +194,10 @@ function startAngularApp(config) { ...@@ -194,7 +194,10 @@ function startAngularApp(config) {
) )
.component('streamContent', require('./components/stream-content')) .component('streamContent', require('./components/stream-content'))
.component('svgIcon', wrapReactComponent(require('./components/svg-icon'))) .component('svgIcon', wrapReactComponent(require('./components/svg-icon')))
.component('tagEditor', require('./components/tag-editor')) .component(
'tagEditor',
wrapReactComponent(require('./components/tag-editor'))
)
.component('tagList', wrapReactComponent(require('./components/tag-list'))) .component('tagList', wrapReactComponent(require('./components/tag-list')))
.component('threadList', require('./components/thread-list')) .component('threadList', require('./components/thread-list'))
.component('topBar', wrapReactComponent(require('./components/top-bar'))) .component('topBar', wrapReactComponent(require('./components/top-bar')))
......
...@@ -37,12 +37,16 @@ ...@@ -37,12 +37,16 @@
h-branding="accentColor">mire</a> h-branding="accentColor">mire</a>
</div> </div>
<!-- Tags --> <!-- Tags -->
<div class="annotation-body" ng-if="vm.editing()"> <tag-editor
<tag-editor tags="vm.state().tags" ng-if="vm.editing()"
on-edit-tags="vm.setTags(tags)"></tag-editor> annotation="vm.annotation"
</div> on-edit-tags="vm.setTags(tags)"
tag-list="vm.state().tags"
/>
</tag-editor>
<tag-list <tag-list
ng-if="!vm.editing()"
annotation="vm.annotation" annotation="vm.annotation"
tags="vm.state().tags" tags="vm.state().tags"
></tag-list> ></tag-list>
......
<tags-input ng-model="vm.tagList"
name="tags"
class="tags"
placeholder="Add tags…"
min-length="1"
replace-spaces-with-dashes="false"
enable-editing-last-tag="true"
on-tag-added="vm.onTagsChanged()"
on-tag-removed="vm.onTagsChanged()">
<auto-complete source="vm.autocomplete($query)"
min-length="1"
max-results-to-show="10"></auto-complete>
</tags-input>
@use "../../mixins/forms";
@use "../../mixins/buttons";
@use "../../variables" as var;
.tag-editor {
&__input {
@include forms.form-input;
width: 100%;
&:focus {
@include forms.form-input-focus;
}
}
&__tag-list {
display: flex;
flex-wrap: wrap;
}
&__tag-item {
display: flex;
margin-right: 0.5em;
margin-bottom: 0.5em;
}
&__edit {
color: var.$grey-6;
background: var.$grey-1;
border: 1px solid var.$grey-3;
border-radius: 2px 0 0 2px;
border-right-width: 0;
padding: 0 0.5em;
}
&__delete {
@include buttons.button-base;
background-color: var.$grey-1;
padding: 0 0.5em;
border: 1px solid var.$grey-3;
border-radius: 0 2px 2px 0;
&:hover {
background-color: var.$grey-2;
}
}
}
...@@ -60,6 +60,7 @@ ...@@ -60,6 +60,7 @@
@use './components/sidebar-panel'; @use './components/sidebar-panel';
@use './components/svg-icon'; @use './components/svg-icon';
@use './components/spinner'; @use './components/spinner';
@use './components/tag-editor';
@use './components/tag-list'; @use './components/tag-list';
@use './components/tags-input'; @use './components/tags-input';
@use './components/thread-list'; @use './components/thread-list';
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment