diff --git a/.changeset/cute-yaks-knock.md b/.changeset/cute-yaks-knock.md new file mode 100644 index 0000000000..f3ad0a45e0 --- /dev/null +++ b/.changeset/cute-yaks-knock.md @@ -0,0 +1,5 @@ +--- +'@openproject/primer-view-components': minor +--- + +Introduce possibility to let the server highlight search results for async FilterableTreeView diff --git a/.github/workflows/release_canary.yml b/.github/workflows/release_canary.yml index 7cca59007a..3c0730f52c 100644 --- a/.github/workflows/release_canary.yml +++ b/.github/workflows/release_canary.yml @@ -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: diff --git a/app/components/primer/open_project/filterable_tree_view.pcss b/app/components/primer/open_project/filterable_tree_view.pcss index d0d9fb0d93..dd5051775e 100644 --- a/app/components/primer/open_project/filterable_tree_view.pcss +++ b/app/components/primer/open_project/filterable_tree_view.pcss @@ -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: 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; } diff --git a/app/components/primer/open_project/filterable_tree_view.rb b/app/components/primer/open_project/filterable_tree_view.rb index 7a5b2700eb..e211279a86 100644 --- a/app/components/primer/open_project/filterable_tree_view.rb +++ b/app/components/primer/open_project/filterable_tree_view.rb @@ -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 `` 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 `` elements. When `false`, the client skips highlighting entirely; the server is responsible for including highlight markup (e.g. `` 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. @@ -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: {}, @@ -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 diff --git a/app/components/primer/open_project/filterable_tree_view.ts b/app/components/primer/open_project/filterable_tree_view.ts index e08d7cf87f..f569aafefa 100644 --- a/app/components/primer/open_project/filterable_tree_view.ts +++ b/app/components/primer/open_project/filterable_tree_view.ts @@ -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) @@ -372,12 +376,12 @@ export class FilterableTreeViewElement extends HTMLElement { if (requestWasFiltered) { this.#expandAllSubTrees() - this.#applyAsyncHighlights(query) + if (this.#clientHighlightsEnabled) this.#applyAsyncHighlights(query) 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') @@ -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) } } diff --git a/app/controllers/primer/view_components/filterable_tree_view_items_controller.rb b/app/controllers/primer/view_components/filterable_tree_view_items_controller.rb index f940664fcc..dc91215d28 100644 --- a/app/controllers/primer/view_components/filterable_tree_view_items_controller.rb +++ b/app/controllers/primer/view_components/filterable_tree_view_items_controller.rb @@ -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 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( + /()([^<]*)(#{escaped_query})([^<]*)(<\/span>)/i, + '\1\2\3\4\5' + ).html_safe + end + + public + def async_form_tree query = params[:query].to_s.strip name = params[:name].to_s.presence || "characters" diff --git a/demo/package-lock.json b/demo/package-lock.json index 2b2f8f6805..2f24c6b87d 100644 --- a/demo/package-lock.json +++ b/demo/package-lock.json @@ -204,9 +204,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -224,9 +221,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -244,9 +238,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -264,9 +255,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -284,9 +272,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -304,9 +289,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -590,9 +572,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -614,9 +593,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -638,9 +614,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -662,9 +635,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package-lock.json b/package-lock.json index 9b43a33ead..16ec94343a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4880,9 +4880,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4900,9 +4897,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4920,9 +4914,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4940,9 +4931,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4960,9 +4948,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4980,9 +4965,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9446,9 +9428,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9470,9 +9449,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9494,9 +9470,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9518,9 +9491,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/previews/primer/open_project/filterable_tree_view_preview.rb b/previews/primer/open_project/filterable_tree_view_preview.rb index b765ac8a06..78b59bc026 100644 --- a/previews/primer/open_project/filterable_tree_view_preview.rb +++ b/previews/primer/open_project/filterable_tree_view_preview.rb @@ -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 `` (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 diff --git a/previews/primer/open_project/filterable_tree_view_preview/async_server_highlights.html.erb b/previews/primer/open_project/filterable_tree_view_preview/async_server_highlights.html.erb new file mode 100644 index 0000000000..626a953d7b --- /dev/null +++ b/previews/primer/open_project/filterable_tree_view_preview/async_server_highlights.html.erb @@ -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) +)) %> diff --git a/test/components/primer/open_project/filterable_tree_view_test.rb b/test/components/primer/open_project/filterable_tree_view_test.rb index f18631c164..643bdc4061 100644 --- a/test/components/primer/open_project/filterable_tree_view_test.rb +++ b/test/components/primer/open_project/filterable_tree_view_test.rb @@ -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 diff --git a/test/system/open_project/filterable_tree_view_test.rb b/test/system/open_project/filterable_tree_view_test.rb index 30ab04f396..147e87d78e 100644 --- a/test/system/open_project/filterable_tree_view_test.rb +++ b/test/system/open_project/filterable_tree_view_test.rb @@ -431,6 +431,29 @@ def test_expands_all_results_when_filtering assert_path(HOGWARTS, "Students", "Gryffindor", "Harry Potter") end + # ─── Server-side highlighting ───────────────────────────────────────────── + + def test_server_highlights_renders_mark_tags_in_matching_nodes + visit_preview(:async_server_highlights, select_variant: :single) + assert_path(HOGWARTS) + + fill_in "Filter", with: "Harry" + + assert_selector "[role=treeitem] mark", text: "Harry" + end + + def test_server_highlights_does_not_render_mark_tags_when_filter_is_cleared + visit_preview(:async_server_highlights, select_variant: :single) + assert_path(HOGWARTS) + + fill_in "Filter", with: "Harry" + assert_selector "[role=treeitem] mark", text: "Harry" + + find(".FormControl button[aria-label='Clear']").click + + assert_no_selector "[role=treeitem] mark" + end + # ─── Expansion state persistence ───────────────────────────────────────── def test_restores_expansion_state_when_filter_is_cleared