-
Notifications
You must be signed in to change notification settings - Fork 65
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
bc58475
commit f58de16
Showing
24 changed files
with
740 additions
and
1,792 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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? ? '×' : '' | ||
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'] | ||
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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('') | ||
} | ||
} |
Oops, something went wrong.