Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a view component for tagging, replacing bootstrap-tagsinput #3078

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
1,008 changes: 316 additions & 692 deletions app/assets/javascripts/spotlight/spotlight.esm.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/assets/javascripts/spotlight/spotlight.esm.js.map

Large diffs are not rendered by default.

1,014 changes: 318 additions & 696 deletions app/assets/javascripts/spotlight/spotlight.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/assets/javascripts/spotlight/spotlight.js.map

Large diffs are not rendered by default.

15 changes: 0 additions & 15 deletions app/assets/stylesheets/spotlight/_catalog.scss
Original file line number Diff line number Diff line change
Expand Up @@ -71,27 +71,12 @@
}

form.edit_solr_document {
.bootstrap-tagsinput {
@extend .clearfix;
cursor: text;
}
.bg-warning.form-text {
font-size: 0.9em;
padding: 3px 6px;
}
}

.bootstrap-tagsinput {
display: block;
.twitter-typeahead {
width: auto;
}

.tt-input {
vertical-align: baseline !important;
}
}

.blacklight-catalog-edit, .blacklight-catalog-show {
.img-thumbnail {
@extend .col-md-6;
Expand Down
1 change: 1 addition & 0 deletions app/assets/stylesheets/spotlight/_spotlight.scss
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
@import "report_a_problem";
@import "exhibits_index";
@import "collapse_toggle";
@import "tag_selector";
@import "translations";
@import "utilities";
@import "view_larger";
Expand Down
34 changes: 34 additions & 0 deletions app/assets/stylesheets/spotlight/_tag_selector.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
.tag-selector {
.tag-selection-search-bar input {
border: none;
outline: none;
}

.tag-selector-input {
display: none;
}

.dropdown-content {
overflow-y: auto;
max-height: 180px;
label {
padding: 5px 10px;
cursor: pointer;

&:hover,
&.active {
background-color: $light;
}
}
}
}

.no-js .tag-selector {
.tag-selector-input {
display: block;
}

.tag-selection-wrapper {
display: none;
}
}
39 changes: 39 additions & 0 deletions app/components/spotlight/tag_selector_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<div data-controller="tag-selector" class="tag-selector" data-tag-selector-translations-value="<%= translation_data.to_json %>" data-tag-selector-tags-value="<%= selected_tags %>" data-tag-selector-close-button-html-value="<%= close_button_html %>">
<% if form.nil? %>
<%= text_field_tag field_name, selected_tags_value, class: 'tag-selector-input', placeholder: t('.no_js_placeholder'), data: { tag_selector_target: "tagsField" } %>
<% else %>
<%= form.text_field field_name, value: selected_tags_value, class: 'tag-selector-input', placeholder: t('.no_js_placeholder'), data: { tag_selector_target: "tagsField" } %>
<% end %>
<input type="hidden" value="<%= selected_tags_value %>" data-tag-selector-target="initialTags">

<div class='mb-3'>
<div class="tag-selection-wrapper" data-tag-selector-target="tagControlWrapper">
<div class="dropdown w-100 mb-3 d-inline-block" data-action="click@window->tag-selector#clickOutside">
<div data-tag-selector-target="textExtractionDropdown">
<div class="border rounded tag-selection-search-bar d-flex">
<button type="button" aria-label="toggle dropdown" class="btn btn-link text-secondary mr-1 me-1" data-action="click->tag-selector#tagDropdown" aria-label="">
<i class="bi bi-search"><%= search_icon_svg.html_safe %></i>
</button>
<input class="flex-grow-1" data-action="input->tag-selector#search input->tag-selector#updateTagToAdd focus->tag-selector#tagDropdown keydown->tag-selector#handleKeydown"
data-tag-selector-target="tagSearch" placeholder="<%= t('.search') %>" aria-label="<%= t('.search') %>">
<button type="button" aria-label="toggle dropdown dropdown-toggle" class="btn btn-link text-secondary dropdown-toggle" data-action="click->tag-selector#tagDropdown" id="caret">
<i class="bi bi-caret-down"></i>
</button>
</div>
</div>
<div id="tag-selection-tags" data-tag-selector-target="dropdownContent" class="dropdown-content d-none tags-group border rounded">
<% all_tags.each do |tag| %>
<label class="d-block">
<input type="checkbox" <%= 'checked' if selected?(tag) %> data-action="click->tag-selector#tagUpdate" data-tag-selector-target="searchResultTags" data-tag="<%= tag %>">
<%= tag %>
</label>
<% end %>
<label class="d-none" data-tag-selector-target="addNewTagWrapper">
<input type="checkbox" data-action="click->tag-selector#tagCreate" data-tag-selector-target="newTag" data-tag=""> Add new tag
</label>
</div>
</div>
<div data-tag-selector-target="selectedTags"></div>
</div>
</div>
</div>
61 changes: 61 additions & 0 deletions app/components/spotlight/tag_selector_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# frozen_string_literal: true

module Spotlight
# Displays a tag selection input
# This uses a plain text input that acts-as-taggable-on expects.
class TagSelectorComponent < ViewComponent::Base
# selected_tags_value is a comma delimited string of tags
def initialize(field_name:, all_tags:, selected_tags_value: nil, form: nil)
@form = form
@field_name = field_name
@selected_tags_value = selected_tags_value || ''
@all_tags = all_tags&.sort_by { |tag| tag.name.downcase }

super
end

def selected_tags
selected_tags_value.split(',').map(&:strip)
end

def close_button_html
# If we remove Bootstrap 4 support, we can remove this.
bootstrap4? ? '&times;' : ''
end

# If we remove Blacklight 7 or Bootstrap 4 support, we can remove this and use one of the built-ins.
def search_icon_svg
<<~SVG
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<path fill="none" d="M0 0h24v24H0V0z"/><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
</svg>
SVG
end

private

def bootstrap_version
bootstrap_gem = Gem.loaded_specs['bootstrap']
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In other places in Spotlight we've added the visually-hidden class to the close button so it will appear for BS4 and not BS5. Would that be possible here too so we don't have to check the version of the installed gem?

<span aria-hidden="true" class="visually-hidden">&times;</span>

bootstrap_gem&.version&.to_s
end

def bootstrap4?
bootstrap_version&.start_with?('4')
end

# To pass to the JS
def translation_data
{
add_new_tag: t('.add_new_tag'),
remove: t('.remove'),
selected_tags: t('.selected_tags')
}
end

def selected?(tag)
selected_tags.include?(tag.name)
end

attr_reader :form, :field_name, :selected_tags_value, :all_tags
end
end
6 changes: 6 additions & 0 deletions app/components/spotlight/tag_selector_component.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
en:
add_new_tag: "Add new tag"
no_js_placeholder: "Enter tags delimited by commas"
remove: "Remove"
search: "Enter Tag"
selected_tags: "Selected tag(s)"
190 changes: 190 additions & 0 deletions app/javascript/controllers/tag_selector_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { Controller } from '@hotwired/stimulus'

export default class extends Controller {

static targets = [
'addNewTagWrapper',
'dropdownContent',
'initialTags',
'newTag',
'searchResultTags',
'selectedTags',
'tagControlWrapper',
'tagSearch',
'tagsField',
'textExtractionDropdown'
]

static values = {
tags: Array,
closeButtonHtml: String,
translations: Object
}

tagDropdown (event) {
const ishidden = this.dropdownContentTarget.classList.contains('d-none')
this.dropdownContentTarget.classList.toggle('d-none')
this.textExtractionDropdownTarget.querySelector('#caret').innerHTML = `<i class="bi bi-caret-${ishidden ? 'up' : 'down'}">`
}

clickOutside (event) {
const isshown = !this.dropdownContentTarget.classList.contains('d-none')
const inselected = event.target.classList.contains('pill-close')
const incontainer = this.tagControlWrapperTarget.contains(event.target)

if (!incontainer && !inselected && isshown) {
this.tagDropdown(event)
}
}

handleKeydown (event) {
if (event.key === 'Enter') {
event.preventDefault()
const tagElementToAdd = this.dropdownContentTarget.querySelector('.active').firstElementChild
if (tagElementToAdd) tagElementToAdd.click()
}

if (event.key === ',') {
event.preventDefault()
this.addNewTagWrapperTarget.click()
this.tagSearchTarget.focus()
}
}

addNewTag (event) {
if (this.addNewTagWrapperTarget.classList.contains('d-none') || this.newTagTarget.dataset.tag.length === 0) {
return
}

this.tagsValue = this.tagsValue.concat([this.newTagTarget.dataset.tag])
this.resetSearch(event)
}

resetSearch(event) {
this.tagSearchTarget.value = ''
this.newTagTarget.innerHTML = ''
this.newTagTarget.dataset.tag = ''
this.addNewTagWrapperTarget.classList.remove('d-block')
this.addNewTagWrapperTarget.classList.add('d-none')

this.searchResultTagsTargets.forEach(target => {
target.parentElement.classList.add('d-block')
target.parentElement.classList.remove('d-none')
})
}

tagUpdate (event) {
const target = event.target ? event.target : event
if (target.checked) {
this.tagsValue = this.tagsValue.concat([target.dataset.tag])
} else {
this.tagsValue = this.tagsValue.filter(tag => tag !== target.dataset.tag)
}
}

tagCreate(event) {
event.preventDefault()
const newTagCheckbox = document.createElement('label')
newTagCheckbox.classList.add('d-block')
newTagCheckbox.innerHTML = `<input type="checkbox" checked data-action="click->${this.identifier}#tagUpdate" data-tag-selector-target="searchResultTags" data-tag="${this.newTagTarget.dataset.tag}"> ${this.newTagTarget.dataset.tag}`

const existingTags = Array.from(this.dropdownContentTarget.querySelectorAll('label:not(#add-new-tag-wrapper)'))
const insertPosition = existingTags.findIndex(tag => tag.textContent.trim().localeCompare(this.newTagTarget.dataset.tag) > 0)
if (insertPosition === -1) {
this.addNewTagWrapperTarget.insertAdjacentElement('beforebegin', newTagCheckbox)
} else {
existingTags[insertPosition].insertAdjacentElement('beforebegin', newTagCheckbox)
}

this.tagsValue = this.tagsValue.concat([this.newTagTarget.dataset.tag])
this.tagSearchTarget.value = ''
this.tagSearchTarget.dispatchEvent(new Event('input'))
}

tagsValueChanged () {
if (this.tagsValue.length === 0) {
this.selectedTagsTarget.classList.add('d-none')
} else {
this.selectedTagsTarget.classList.remove('d-none')
this.selectedTagsTarget.innerHTML = `<div>${this.translationsValue.selected_tags}</div>
<ul class="list-unstyled border rounded mb-3 p-1">${this.renderTagPills()}</ul>`
}

// The backend expects the comma with the space. If we're not careful here, observedFormsStatusHasChanged
// will return true and warn the user that the form has changed, even when it really hasn't.
const newValue = this.tagsValue.join(', ')
if (this.tagsFieldTarget.value !== newValue) {
this.tagsFieldTarget.value = newValue
}
}

search (event) {
const normalizeRegex = /[^\w\s]/gi
const searchTerm = event.target.value.replace(normalizeRegex, '').toLowerCase().trim()
let exactMatch = false
this.dropdownContentTarget.classList.remove('d-none')

this.searchResultTagsTargets.forEach(target => {
target.parentElement.classList.remove('active')
const compareTerm = target.dataset.tag.replace(normalizeRegex, '').toLowerCase().trim()
if (compareTerm.includes(searchTerm)) {
target.parentElement.classList.add('d-block')
target.parentElement.classList.remove('d-none')
if (compareTerm === searchTerm) exactMatch = true
} else {
target.parentElement.classList.add('d-none')
target.parentElement.classList.remove('d-block')
}
})

if (searchTerm.length > 0 && !exactMatch) {
this.addNewTagWrapperTarget.classList.remove('d-none')
this.addNewTagWrapperTarget.classList.add('d-block')
} else {
this.addNewTagWrapperTarget.classList.add('d-none')
this.addNewTagWrapperTarget.classList.remove('d-block')
}
this.addNewTagWrapperTarget.classList.remove('active')

const firstVisibleTag = this.dropdownContentTarget.querySelector('label.d-block')
if (firstVisibleTag) {
firstVisibleTag.classList.add('active')
}
}

updateTagToAdd (event) {
this.newTagTarget.dataset.tag = event.target.value.trim()
this.newTagTarget.nextSibling.textContent = ` ${this.translationsValue.add_new_tag}: ${event.target.value}`
}

deselect (event) {
event.preventDefault()

const target = this.searchResultTagsTargets.find((tag) => tag.dataset.tag === event.target.dataset.tag)
if (target) {
target.checked = false
this.tagUpdate(target)
} else {
this.tagsValue = this.tagsValue.filter(tag => tag !== event.target.dataset.tag)
}
}

renderTagPills () {
return this.tagsValue.map((tag) => {
return `
<li class="d-inline-flex gap-2 align-items-center my-2">
<span class="bg-light badge rounded-pill border selected-item d-inline-flex align-items-center text-dark">
<span class="selected-item-label d-inline-flex">${tag}</span>
<button
type="button"
data-action="${this.identifier}#deselect"
data-tag="${tag}"
class="btn-close close ms-1 ml-1"
aria-label="${this.translationsValue.remove} ${tag}"
>${this.closeButtonHtmlValue}</button>
</span>
</li>
`
}).join('')
}
}
Loading