Skip to content

Commit 63a2bcb

Browse files
committed
Introduce possibility to let the server highlight search results for async FilterableTreeView
1 parent 3243927 commit 63a2bcb

11 files changed

Lines changed: 101 additions & 69 deletions

File tree

.changeset/cute-yaks-knock.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@openproject/primer-view-components': minor
3+
---
4+
5+
Introduce possibility to let the server highlight search results for async FilterableTreeView

app/components/primer/open_project/filterable_tree_view.pcss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,13 @@ filterable-tree-view {
3030

3131
/* Highlight style for CSS Custom Highlight API */
3232
::highlight(primer-filterable-tree-view-search-results) {
33-
background-color: var(--bgColor-attention-muted);
33+
background-color: var(--display-yellow-scale-2);
3434
}
3535

3636
/* Fallback: <mark> elements used when CSS Custom Highlight API is unavailable */
3737
/* stylelint-disable-next-line selector-max-type */
3838
filterable-tree-view mark {
39-
background-color: var(--bgColor-attention-muted);
39+
background-color: var(--display-yellow-scale-2);
4040
color: inherit;
4141
}
4242

app/components/primer/open_project/filterable_tree_view.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ class FilterableTreeView < Primer::Component
189189
DEFAULT_NO_RESULTS_NODE_ARGUMENTS.freeze
190190

191191
# @param src [String] URL of the server endpoint that returns a filtered `<tree-view>` HTML fragment. When set, activates async (server-side) filtering mode. See "Async loading strategy" above.
192+
# @param show_search_highlighting [Boolean] Only relevant in async mode (`src:` must be set). When `true` (default), the client highlights matching text using the CSS Custom Highlight API or `<mark>` elements. When `false`, the client skips highlighting entirely; the server is responsible for including highlight markup (e.g. `<mark>` tags) in the returned HTML fragment.
192193
# @param tree_view_arguments [Hash] Arguments that will be passed to the underlying <%= link_to_component(Primer::Alpha::TreeView) %> component.
193194
# @param form_arguments [Hash] Form arguments that will be passed to the underlying <%= link_to_component(Primer::Alpha::TreeView) %> component. These arguments allow the selections made within a `FilterableTreeView` to be submitted to the server as part of a Rails form. Pass the `builder:` and `name:` options to this hash. `builder:` should be an instance of `ActionView::Helpers::FormBuilder`, which are created by the standard Rails `#form_with` and `#form_for` helpers. The `name:` option is the desired name of the field that will be included in the params sent to the server on form submission.
194195
# @param filter_input_arguments [Hash] Arguments that will be passed to the <%= link_to_component(Primer::Alpha::TextField) %> component.
@@ -197,6 +198,7 @@ class FilterableTreeView < Primer::Component
197198
# @param no_results_node_arguments [Hash] Arguments that will be passed to a <%= link_to_component(Primer::Alpha::TreeView::LeafNode) %> component that appears when no items match the filter criteria.
198199
def initialize(
199200
src: nil,
201+
show_search_highlighting: true,
200202
tree_view_arguments: {},
201203
form_arguments: {},
202204
filter_input_arguments: {},
@@ -250,6 +252,11 @@ def initialize(
250252
@system_arguments = deny_tag_argument(**system_arguments)
251253
@system_arguments[:tag] = :"filterable-tree-view"
252254
@system_arguments[:src] = src if src
255+
@system_arguments[:data] = merge_data(
256+
@system_arguments, {
257+
data: { show_search_highlighting: false }
258+
}
259+
) unless show_search_highlighting
253260

254261
@no_results_node_arguments = no_results_node_arguments.reverse_merge(DEFAULT_NO_RESULTS_NODE_ARGUMENTS)
255262
end

app/components/primer/open_project/filterable_tree_view.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ export class FilterableTreeViewElement extends HTMLElement {
6969
return !!this.#src
7070
}
7171

72+
get #clientHighlightsEnabled(): boolean {
73+
return this.getAttribute('data-show-search-highlighting') !== 'false'
74+
}
75+
7276
handleEvent(event: Event) {
7377
if (event.target === this.filterModeControl) {
7478
this.#handleFilterModeEvent(event)
@@ -372,12 +376,12 @@ export class FilterableTreeViewElement extends HTMLElement {
372376

373377
if (requestWasFiltered) {
374378
this.#expandAllSubTrees()
375-
this.#applyAsyncHighlights(query)
379+
if (this.#clientHighlightsEnabled) this.#applyAsyncHighlights(query)
376380
const hasResults = !!this.treeViewList?.querySelector('[role=treeitem]')
377381
this.noResultsMessage.toggleAttribute('hidden', hasResults)
378382
this.treeViewList?.toggleAttribute('hidden', !hasResults)
379383
} else {
380-
this.#removeHighlights()
384+
if (this.#clientHighlightsEnabled) this.#removeHighlights()
381385
this.#restoreExpansionState()
382386
this.noResultsMessage.setAttribute('hidden', 'hidden')
383387
this.treeViewList?.removeAttribute('hidden')

app/controllers/primer/view_components/filterable_tree_view_items_controller.rb

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -165,15 +165,32 @@ def self.branch(id:, label:, children:)
165165
def index
166166
query = params[:query].to_s.strip
167167
select_variant = (params[:select_variant].presence || "multiple").to_sym
168+
server_highlights = params[:server_highlights] == "true"
168169
nodes = TREE.filter_map { |node| node.filter(query) }
169170

170-
render locals: {
171-
nodes: nodes,
172-
query: query,
173-
select_variant: select_variant
174-
}
171+
if server_highlights && query.present?
172+
html = render_to_string locals: { nodes:, query:, select_variant: }
173+
render html: inject_highlights(html, query)
174+
else
175+
render locals: { nodes:, query:, select_variant: }
176+
end
175177
end
176178

179+
private
180+
181+
# Post-processes the rendered tree HTML to wrap matching label text in <mark> tags.
182+
# This avoids html_safety issues with passing markup through ViewComponent's label slot.
183+
# Only called in the demo endpoint when server_highlights=true is requested.
184+
def inject_highlights(html, query)
185+
escaped_query = Regexp.escape(CGI.escapeHTML(query))
186+
html.gsub(
187+
/(<span class="TreeViewItemContentText">)([^<]*)(#{escaped_query})([^<]*)(<\/span>)/i,
188+
'\1\2<mark>\3</mark>\4\5'
189+
).html_safe
190+
end
191+
192+
public
193+
177194
def async_form_tree
178195
query = params[:query].to_s.strip
179196
name = params[:name].to_s.presence || "characters"

demo/package-lock.json

Lines changed: 0 additions & 30 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package-lock.json

Lines changed: 0 additions & 30 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

previews/primer/open_project/filterable_tree_view_preview.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,18 @@ def async(select_variant: :single)
123123
})
124124
end
125125

126+
# @label Async (with server-side highlighting)
127+
#
128+
# Demonstrates `show_search_highlighting: false`: the client skips its own highlight logic
129+
# and the server is responsible for wrapping matching text in `<mark>` (or other HTML) elements.
130+
#
131+
# @param select_variant [Symbol] select [multiple, single, none]
132+
def async_server_highlights(select_variant: :single)
133+
render_with_template(locals: {
134+
select_variant: select_variant.to_sym
135+
})
136+
end
137+
126138
# @label Async form input
127139
def async_form_input
128140
render_with_template
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<%= render(Primer::OpenProject::FilterableTreeView.new(
2+
show_search_highlighting: false,
3+
src: primer_view_components.filterable_tree_view_items_tree_path(select_variant: select_variant, server_highlights: true)
4+
)) %>

test/components/primer/open_project/filterable_tree_view_test.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,26 @@ def test_custom_filter_modes
274274
assert_selector "segmented-control li", text: "Foo"
275275
end
276276

277+
# ─── show_search_highlighting ──────────────────────────────────────────
278+
279+
def test_show_search_highlighting_true_by_default_sets_no_data_attribute
280+
render_inline(Primer::OpenProject::FilterableTreeView.new)
281+
282+
assert_selector "filterable-tree-view:not([data-show-search-highlighting])"
283+
end
284+
285+
def test_show_search_highlighting_false_sets_data_attribute
286+
render_inline(Primer::OpenProject::FilterableTreeView.new(show_search_highlighting: false))
287+
288+
assert_selector "filterable-tree-view[data-show-search-highlighting='false']"
289+
end
290+
291+
def test_show_search_highlighting_true_sets_no_data_attribute
292+
render_inline(Primer::OpenProject::FilterableTreeView.new(show_search_highlighting: true))
293+
294+
assert_selector "filterable-tree-view:not([data-show-search-highlighting])"
295+
end
296+
277297
# ─── Async mode ────────────────────────────────────────────────────────
278298

279299
def test_src_attribute_is_set_when_src_is_provided

0 commit comments

Comments
 (0)