Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/cute-yaks-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@openproject/primer-view-components': minor
---

Introduce possibility to let the server highlight search results for async FilterableTreeView
1 change: 1 addition & 0 deletions .github/workflows/release_canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ jobs:
run: |
echo "$( jq '.version = "0.0.0"' package.json )" > package.json
echo -e "---\n$( jq .name package.json ): patch\n---\n\nFake entry to force publishing" > .changeset/force-snapshot-release.md
jq '.changelog = "@changesets/cli/changelog"' .changeset/config.json > .changeset/config.tmp.json && mv .changeset/config.tmp.json .changeset/config.json
npx changeset version --snapshot
npx changeset publish --tag canary
env:
Expand Down
4 changes: 2 additions & 2 deletions app/components/primer/open_project/filterable_tree_view.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ filterable-tree-view {

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

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

Expand Down
7 changes: 7 additions & 0 deletions app/components/primer/open_project/filterable_tree_view.rb
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ class FilterableTreeView < Primer::Component
DEFAULT_NO_RESULTS_NODE_ARGUMENTS.freeze

# @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.
# @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.
# @param tree_view_arguments [Hash] Arguments that will be passed to the underlying <%= link_to_component(Primer::Alpha::TreeView) %> component.
# @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.
# @param filter_input_arguments [Hash] Arguments that will be passed to the <%= link_to_component(Primer::Alpha::TextField) %> component.
Expand All @@ -197,6 +198,7 @@ class FilterableTreeView < Primer::Component
# @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.
def initialize(
src: nil,
show_search_highlighting: true,
tree_view_arguments: {},
form_arguments: {},
filter_input_arguments: {},
Expand Down Expand Up @@ -250,6 +252,11 @@ def initialize(
@system_arguments = deny_tag_argument(**system_arguments)
@system_arguments[:tag] = :"filterable-tree-view"
@system_arguments[:src] = src if src
@system_arguments[:data] = merge_data(
@system_arguments, {
data: { show_search_highlighting: false }
}
) unless show_search_highlighting

@no_results_node_arguments = no_results_node_arguments.reverse_merge(DEFAULT_NO_RESULTS_NODE_ARGUMENTS)
end
Expand Down
10 changes: 7 additions & 3 deletions app/components/primer/open_project/filterable_tree_view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export class FilterableTreeViewElement extends HTMLElement {
return !!this.#src
}

get #clientHighlightsEnabled(): boolean {
return this.getAttribute('data-show-search-highlighting') !== 'false'
}

handleEvent(event: Event) {
if (event.target === this.filterModeControl) {
this.#handleFilterModeEvent(event)
Expand Down Expand Up @@ -372,12 +376,12 @@ export class FilterableTreeViewElement extends HTMLElement {

if (requestWasFiltered) {
this.#expandAllSubTrees()
this.#applyAsyncHighlights(query)
if (this.#clientHighlightsEnabled) this.#applyAsyncHighlights(query)
Comment thread
HDinger marked this conversation as resolved.
const hasResults = !!this.treeViewList?.querySelector('[role=treeitem]')
this.noResultsMessage.toggleAttribute('hidden', hasResults)
this.treeViewList?.toggleAttribute('hidden', !hasResults)
} else {
this.#removeHighlights()
if (this.#clientHighlightsEnabled) this.#removeHighlights()
this.#restoreExpansionState()
this.noResultsMessage.setAttribute('hidden', 'hidden')
this.treeViewList?.removeAttribute('hidden')
Expand Down Expand Up @@ -700,7 +704,7 @@ export class FilterableTreeViewElement extends HTMLElement {
this.treeViewList.removeAttribute('hidden')
this.noResultsMessage.setAttribute('hidden', 'hidden')

this.#applyHighlights(allRanges)
if (this.#clientHighlightsEnabled) this.#applyHighlights(allRanges)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,15 +165,32 @@ def self.branch(id:, label:, children:)
def index
query = params[:query].to_s.strip
select_variant = (params[:select_variant].presence || "multiple").to_sym
server_highlights = params[:server_highlights] == "true"
nodes = TREE.filter_map { |node| node.filter(query) }

render locals: {
nodes: nodes,
query: query,
select_variant: select_variant
}
if server_highlights && query.present?
html = render_to_string locals: { nodes:, query:, select_variant: }
render html: inject_highlights(html, query)
else
render locals: { nodes:, query:, select_variant: }
end
end

private

# Post-processes the rendered tree HTML to wrap matching label text in <mark> tags.
# This avoids html_safety issues with passing markup through ViewComponent's label slot.
# Only called in the demo endpoint when server_highlights=true is requested.
def inject_highlights(html, query)
escaped_query = Regexp.escape(CGI.escapeHTML(query))
html.gsub(
/(<span class="TreeViewItemContentText">)([^<]*)(#{escaped_query})([^<]*)(<\/span>)/i,
'\1\2<mark>\3</mark>\4\5'
).html_safe
end

public

def async_form_tree
query = params[:query].to_s.strip
name = params[:name].to_s.presence || "characters"
Expand Down
30 changes: 0 additions & 30 deletions demo/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 0 additions & 30 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions previews/primer/open_project/filterable_tree_view_preview.rb
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ def async(select_variant: :single)
})
end

# @label Async (with server-side highlighting)
#
# Demonstrates `show_search_highlighting: false`: the client skips its own highlight logic
# and the server is responsible for wrapping matching text in `<mark>` (or other HTML) elements.
#
# @param select_variant [Symbol] select [multiple, single, none]
def async_server_highlights(select_variant: :single)
render_with_template(locals: {
select_variant: select_variant.to_sym
})
end

# @label Async form input
def async_form_input
render_with_template
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<%= render(Primer::OpenProject::FilterableTreeView.new(
show_search_highlighting: false,
src: primer_view_components.filterable_tree_view_items_tree_path(select_variant: select_variant, server_highlights: true)
)) %>
20 changes: 20 additions & 0 deletions test/components/primer/open_project/filterable_tree_view_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,26 @@ def test_custom_filter_modes
assert_selector "segmented-control li", text: "Foo"
end

# ─── show_search_highlighting ──────────────────────────────────────────

def test_show_search_highlighting_true_by_default_sets_no_data_attribute
render_inline(Primer::OpenProject::FilterableTreeView.new)

assert_selector "filterable-tree-view:not([data-show-search-highlighting])"
end

def test_show_search_highlighting_false_sets_data_attribute
render_inline(Primer::OpenProject::FilterableTreeView.new(show_search_highlighting: false))

assert_selector "filterable-tree-view[data-show-search-highlighting='false']"
end

def test_show_search_highlighting_true_sets_no_data_attribute
render_inline(Primer::OpenProject::FilterableTreeView.new(show_search_highlighting: true))

assert_selector "filterable-tree-view:not([data-show-search-highlighting])"
end

# ─── Async mode ────────────────────────────────────────────────────────

def test_src_attribute_is_set_when_src_is_provided
Expand Down
Loading
Loading