diff --git a/asset/css/search-field.less b/asset/css/search-field.less new file mode 100644 index 00000000..76fb7577 --- /dev/null +++ b/asset/css/search-field.less @@ -0,0 +1,81 @@ +form.search-field { + .search-icon { + padding: 0.5em 0 0.5em 0.5em; + background-color: @searchbar-bg; + .rounded-corners(0.25em); + border-top-right-radius: unset; + border-bottom-right-radius: unset; + min-height: 26px; + margin-bottom: 0.25em; + } + + input.search-field { + .rounded-corners(0.25em); + .appearance(none); + padding: 0.5em; + margin-bottom: 0.5em; + margin-right: 1em; + background-color: @searchbar-bg; + border: none; + border-top-left-radius: unset; + border-bottom-left-radius: unset; + background-image: unset; + } + + input[type="submit"] { + .rounded-corners(3px); + margin-bottom: 0.5em; + max-width: 10em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background-color: @primary-button-bg; + color: @primary-button-color; + border: 2px solid @primary-button-bg; + padding: ~"calc(0.5em - 2px) 1em"; + } + + #term-container { + margin-top: 1em; + + label { + position: relative; + + .trash-icon { + display: none; + position: absolute; + right: 9px; + top: 1px; + } + + &:hover { + //badge for read-only mode + input[type="button"] { + background-color: @search-term-selected-bg; + color: @search-term-selected-color; + } + + .trash-icon { + display: inline-block; + color: @cancel-button-color; + background-color: @search-term-selected-bg; + } + } + + //badge + input[type="button"], input[type="text"] { + .rounded-corners(0.25em); + border: none; + padding: 0.5em; + margin-right: 0.5em; + margin-bottom: 0.5em; + max-width: 10em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background-color: @search-term-bg; + color: @search-term-color; + } + } + } +} \ No newline at end of file diff --git a/src/Control/SimpleSearchField.php b/src/Control/SimpleSearchField.php new file mode 100644 index 00000000..0b403ff3 --- /dev/null +++ b/src/Control/SimpleSearchField.php @@ -0,0 +1,170 @@ + 'search-field', + 'name' => 'search-field', + 'role' => 'search' + ]; + + /** @var string The term separator */ + public const TERM_SEPARATOR = ','; + + /** @var string The default search parameter */ + public const DEFAULT_SEARCH_PARAM = 'q'; + + /** @var string The search parameter */ + protected $searchParameter; + + /** @var Url The suggestion url */ + protected $suggestionUrl; + + /** @var string Submit label */ + protected $submitLabel; + + /** + * Set the search parameter + * + * @param string $name + * + * @return $this + */ + public function setSearchParameter(string $name): self + { + $this->searchParameter = $name; + + return $this; + } + + /** + * Get the search parameter + * + * @return string + */ + public function getSearchParameter(): string + { + return $this->searchParameter ?: self::DEFAULT_SEARCH_PARAM; + } + + /** + * Set the suggestion url + * + * @param Url $url + * + * @return $this + */ + public function setSuggestionUrl(Url $url): self + { + $this->suggestionUrl = $url; + + return $this; + } + + /** + * Get the suggestion url + * + * @return Url + */ + public function getSuggestionUrl(): Url + { + return $this->suggestionUrl; + } + + /** + * Set submit label + * + * @param string $label + * + * @return $this + */ + public function setSubmitLabel(string $label): self + { + $this->submitLabel = $label; + + return $this; + } + + /** + * Get submit label + * + * @return string + */ + public function getSubmitLabel(): string + { + return $this->submitLabel ?? t('Submit'); + } + + public function assemble() + { + $filterInput = new InputElement(null, [ + 'type' => 'text', + 'placeholder' => t('Type to search'), + 'class' => 'search-field', + 'id' => 'search-filed', + 'autocomplete' => 'off', + 'required' => true, + 'data-no-auto-submit' => true, + 'data-no-js-placeholder' => true, + 'data-enrichment-type' => 'terms', + 'data-term-separator' => self::TERM_SEPARATOR, + 'data-term-mode' => 'read-only', + 'data-term-direction' => 'vertical', + 'data-data-input' => '#data-input', + 'data-term-input' => '#term-input', + 'data-term-container' => '#term-container', + 'data-term-suggestions' => '#term-suggestions', + 'data-suggest-url' => $this->getSuggestionUrl() + ]); + + $dataInput = new InputElement('data', ['type' => 'hidden', 'id' => 'data-input']); + + $termInput = new InputElement($this->getSearchParameter(), ['type' => 'hidden', 'id' => 'term-input']); + $this->registerElement($termInput); + + $termContainer = new HtmlElement( + 'div', + Attributes::create(['id' => 'term-container', 'class' => 'term-container']) + ); + + $termSuggestions = new HtmlElement( + 'div', + Attributes::create(['id' => 'term-suggestions', 'class' => 'search-suggestions']) + ); + + $submitButton = new SubmitElement('submit', ['label' => $this->getSubmitLabel()]); + + $this->registerElement($submitButton); + + $this->add([ + HtmlElement::create( + 'div', + null, + [ + new Icon('search', ['class' => 'search-icon']), + $filterInput, + $termSuggestions, + $dataInput, + $termInput, + $submitButton + ] + ), + $termContainer + ]); + } +} diff --git a/src/Control/SimpleSuggestions.php b/src/Control/SimpleSuggestions.php new file mode 100644 index 00000000..10cd11f0 --- /dev/null +++ b/src/Control/SimpleSuggestions.php @@ -0,0 +1,196 @@ +searchTerm = $searchTerm; + + return $this; + } + + /** + * Set the fetched data + * + * @param mixed $data + * + * @return $this + */ + public function setData($data): self + { + $this->data = $data; + + return $this; + } + + /** + * Set the default suggestion + * + * @param string $default + * + * @return $this + */ + public function setDefault(string $default): self + { + $this->default = trim($default, '"\''); + + return $this; + } + + /** + * Fetch suggestions according to the input in the search field + * + * @param string $searchTerm The given input in the search field + * @param array $exclude Already added terms to be excluded from the suggestion list + * + * @return mixed + */ + abstract protected function fetchSuggestions(string $searchTerm, array $exclude = []); + + protected function assembleDefault(): void + { + if ($this->default === null) { + return; + } + + $attributes = [ + 'type' => 'button', + 'tabindex' => -1, + 'data-label' => $this->default, + 'value' => $this->default, + ]; + + $button = new ButtonElement(null, $attributes); + $button->addHtml(FormattedString::create( + t('Add %s'), + new HtmlElement('em', null, Text::create($this->default)) + )); + + $this->prependHtml(new HtmlElement('li', Attributes::create(['class' => 'default']), $button)); + } + + protected function assemble() + { + if ($this->data === null) { + $data = []; + } else { + $data = $this->data; + if (is_array($data)) { + $data = new ArrayIterator($data); + } + + $data = new LimitIterator(new IteratorIterator($data), 0, self::DEFAULT_LIMIT); + } + + foreach ($data as $term => $label) { + if (is_int($term)) { + $term = $label; + } + + $attributes = [ + 'type' => 'button', + 'tabindex' => -1, + 'data-search' => $term + ]; + + $attributes['value'] = $label; + $attributes['data-label'] = $label; + + $this->addHtml(new HtmlElement('li', null, new InputElement(null, $attributes))); + } + + $showDefault = true; + if ($this->searchTerm && $this->count() === 1) { + // The default option is not shown if the user's input result in an exact match + $input = $this->getFirst('li')->getFirst('input'); + $showDefault = $input->getValue() != $this->searchTerm + && $input->getAttributes()->get('data-search')->getValue() != $this->searchTerm; + } + + if ($showDefault) { + $this->assembleDefault(); + } + } + + /** + * Load suggestions as requested by the client + * + * @param ServerRequestInterface $request + * + * @return $this + */ + public function forRequest(ServerRequestInterface $request): self + { + if ($request->getMethod() !== 'POST') { + return $this; + } + + $requestData = json_decode($request->getBody()->read(8192), true); + if (empty($requestData)) { + return $this; + } + + $search = $requestData['term']['search']; + $label = $requestData['term']['label']; + $exclude = $requestData['exclude']; + + $this->setSearchTerm($search); + + $this->setData($this->fetchSuggestions($label, $exclude)); + + if (! empty($search)) { + $this->setDefault($search); + } + + return $this; + } + + public function renderUnwrapped() + { + $this->ensureAssembled(); + + if ($this->isEmpty()) { + return ''; + } + + return parent::renderUnwrapped(); + } +}