- <%= render component("ui/thumbnail").new(
- src: @image&.url(:small),
- alt: @variant.name
- ) %>
-
-
<%= @variant.name %>
-
- SKU:
- <%= @variant.sku %>
- <%= @variant.options_text.presence&.prepend("- ") %>
+<%= render component('ui/search_panel/result').new do %>
+ <%= form_for(@order.line_items.build(variant: @variant), url: solidus_admin.order_line_items_path(@order), method: :post, html: {
+ "data-controller": "readonly-when-submitting",
+ class: "flex items-center",
+ }) do |f| %>
+ <%= hidden_field_tag("#{f.object_name}[variant_id]", @variant.id) %>
+
+ <%= render component("ui/thumbnail").new(
+ src: @image&.url(:small),
+ alt: @variant.name
+ ) %>
+
+
<%= @variant.name %>
+
+ SKU:
+ <%= @variant.sku %>
+ <%= @variant.options_text.presence&.prepend("- ") %>
+
-
-
- <%= render component("products/stock").from_variant(@variant) %>
- <%= @variant.display_price.to_html %>
-
+
+ <%= render component("products/stock").from_variant(@variant) %>
+ <%= @variant.display_price.to_html %>
+
+ <% end %>
<% end %>
diff --git a/admin/app/components/solidus_admin/orders/cart/result/component.js b/admin/app/components/solidus_admin/orders/cart/result/component.js
deleted file mode 100644
index 6778c98c6da..00000000000
--- a/admin/app/components/solidus_admin/orders/cart/result/component.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import { Controller } from '@hotwired/stimulus'
-
-export default class extends Controller {
- submit(event) {
- event.currentTarget.requestSubmit()
- }
-}
diff --git a/admin/app/components/solidus_admin/ui/badge/component.rb b/admin/app/components/solidus_admin/ui/badge/component.rb
index 3aa1a5ee0be..5f33c1b81c8 100644
--- a/admin/app/components/solidus_admin/ui/badge/component.rb
+++ b/admin/app/components/solidus_admin/ui/badge/component.rb
@@ -1,8 +1,6 @@
# frozen_string_literal: true
class SolidusAdmin::UI::Badge::Component < SolidusAdmin::BaseComponent
- include ViewComponent::InlineTemplate
-
COLORS = {
graphite_light: "text-black bg-graphite-light",
red: 'text-red-500 bg-red-100',
diff --git a/admin/app/components/solidus_admin/ui/search_panel/component.html.erb b/admin/app/components/solidus_admin/ui/search_panel/component.html.erb
new file mode 100644
index 00000000000..bb4403ca1ab
--- /dev/null
+++ b/admin/app/components/solidus_admin/ui/search_panel/component.html.erb
@@ -0,0 +1,58 @@
+
-loading-text-value="<%= t('.loading') %>"
+ data-<%= stimulus_id %>-initial-text-value="<%= t('.initial') %>"
+ data-<%= stimulus_id %>-empty-text-value="<%= t('.empty') %>"
+>
+ <%= render component('ui/panel').new(**@panel_args) do |panel| %>
+
+
+
+ <%= render component("ui/forms/search_field").new(
+ id: "#{stimulus_id}--search-field--#{@id}",
+ placeholder: @search_placeholder,
+ "data-action": "
+ #{stimulus_id}#search
+ #{stimulus_id}#showResults
+ ",
+ "data-#{stimulus_id}-target": "searchField",
+ ) %>
+
+
+
+
+ -target="results"
+ >
+
+
+
+
+
+ <%= content %>
+
+ <% end %>
+
diff --git a/admin/app/components/solidus_admin/ui/search_panel/component.js b/admin/app/components/solidus_admin/ui/search_panel/component.js
new file mode 100644
index 00000000000..d37938d1d7a
--- /dev/null
+++ b/admin/app/components/solidus_admin/ui/search_panel/component.js
@@ -0,0 +1,116 @@
+import { Controller } from "@hotwired/stimulus"
+import { useClickOutside, useDebounce } from "stimulus-use"
+
+export default class extends Controller {
+ static targets = ["result", "results", "searchField"]
+ static values = {
+ results: String,
+ loadingText: String,
+ initialText: String,
+ emptyText: String,
+ }
+ static debounces = ["search"]
+
+ get query() {
+ return this.searchFieldTarget.value
+ }
+
+ get selectedResult() {
+ // Keep the index within boundaries
+ if (this.selectedIndex < 0) this.selectedIndex = 0
+ if (this.selectedIndex >= this.resultTargets.length) this.selectedIndex = this.resultTargets.length - 1
+
+ return this.resultTargets[this.selectedIndex]
+ }
+
+ connect() {
+ useClickOutside(this)
+ useDebounce(this)
+
+ this.selectedIndex = 0
+
+ if (this.query) {
+ this.showResults()
+ this.search()
+ }
+ }
+
+ selectResult(event) {
+ event.preventDefault()
+ this.dispatch("submit", { detail: { resultTarget: this.selectedResult } })
+ }
+
+ clickedResult(event) {
+ this.selectedIndex = this.resultTargets.indexOf(event.currentTarget)
+ this.render()
+ this.selectResult(event)
+ }
+
+ selectPrev(event) {
+ event.preventDefault()
+ this.selectedIndex -= 1
+ this.render()
+ }
+
+ selectNext(event) {
+ event.preventDefault()
+ this.selectedIndex += 1
+ this.render()
+ }
+
+ clickOutside() {
+ this.openResults = false
+ this.render()
+ }
+
+ async search() {
+ const query = this.query
+
+ if (query) {
+ this.resultsValue = this.loadingTextValue
+ this.render()
+ this.dispatch("search", { detail: { query, controller: this } })
+ } else {
+ this.resultsValue = this.initialTextValue
+ this.render()
+ }
+ }
+
+ resultsValueChanged() {
+ this.selectedIndex = 0
+ this.render()
+ }
+
+ showResults() {
+ this.openResults = true
+ this.render()
+ }
+
+ render() {
+ let resultsHtml = this.resultsValue
+
+ if (this.renderedHtml !== resultsHtml) {
+ this.renderedHtml = resultsHtml
+ this.resultsTarget.innerHTML = resultsHtml
+ }
+
+ if (this.openResults && resultsHtml && this.query) {
+ if (!this.resultsTarget.parentNode.open) this.selectedIndex = 0
+
+ for (const result of this.resultTargets) {
+ if (result === this.selectedResult) {
+ if (!result.hasAttribute("aria-selected") && result.scrollIntoViewIfNeeded) {
+ // This is a non-standard method, but it's supported by all major browsers
+ result.scrollIntoViewIfNeeded()
+ }
+ result.setAttribute("aria-selected", true)
+ } else {
+ result.removeAttribute("aria-selected")
+ }
+ }
+ this.resultsTarget.parentNode.open = true
+ } else {
+ this.resultsTarget.parentNode.open = false
+ }
+ }
+}
diff --git a/admin/app/components/solidus_admin/ui/search_panel/component.rb b/admin/app/components/solidus_admin/ui/search_panel/component.rb
new file mode 100644
index 00000000000..b8f32ac5382
--- /dev/null
+++ b/admin/app/components/solidus_admin/ui/search_panel/component.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class SolidusAdmin::UI::SearchPanel::Component < SolidusAdmin::BaseComponent
+ def initialize(search_placeholder: nil, id: nil, **panel_args)
+ @search_placeholder = search_placeholder
+ @panel_args = panel_args
+ @id = id
+ end
+end
diff --git a/admin/app/components/solidus_admin/ui/search_panel/component.yml b/admin/app/components/solidus_admin/ui/search_panel/component.yml
new file mode 100644
index 00000000000..05ebdc5b93c
--- /dev/null
+++ b/admin/app/components/solidus_admin/ui/search_panel/component.yml
@@ -0,0 +1,4 @@
+en:
+ loading: "Loading..."
+ initial: "Type to search"
+ empty: "No results"
diff --git a/admin/app/components/solidus_admin/ui/search_panel/result/component.rb b/admin/app/components/solidus_admin/ui/search_panel/result/component.rb
new file mode 100644
index 00000000000..520bbbce5ed
--- /dev/null
+++ b/admin/app/components/solidus_admin/ui/search_panel/result/component.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class SolidusAdmin::UI::SearchPanel::Result::Component < SolidusAdmin::BaseComponent
+ def call
+ tag.div(
+ content,
+ class: "rounded p-2 hover:bg-gray-25 aria-selected:bg-gray-25 cursor-pointer",
+ "data-#{component('ui/search_panel').stimulus_id}-target": "result",
+ "data-action": "click->#{component('ui/search_panel').stimulus_id}#clickedResult",
+ )
+ end
+end
diff --git a/admin/spec/components/previews/solidus_admin/ui/search_panel/component_preview.rb b/admin/spec/components/previews/solidus_admin/ui/search_panel/component_preview.rb
new file mode 100644
index 00000000000..cc3ab94a5dc
--- /dev/null
+++ b/admin/spec/components/previews/solidus_admin/ui/search_panel/component_preview.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+# @component "ui/search_panel"
+class SolidusAdmin::UI::SearchPanel::ComponentPreview < ViewComponent::Preview
+ include SolidusAdmin::Preview
+
+ def overview
+ render_with_template
+ end
+end
diff --git a/admin/spec/components/previews/solidus_admin/ui/search_panel/component_preview/overview.html.erb b/admin/spec/components/previews/solidus_admin/ui/search_panel/component_preview/overview.html.erb
new file mode 100644
index 00000000000..6699a39eaef
--- /dev/null
+++ b/admin/spec/components/previews/solidus_admin/ui/search_panel/component_preview/overview.html.erb
@@ -0,0 +1,24 @@
+
+
+ Default
+
+
+
+
+ <%= render current_component.new %>
+
diff --git a/admin/spec/components/solidus_admin/ui/search_panel/component_spec.rb b/admin/spec/components/solidus_admin/ui/search_panel/component_spec.rb
new file mode 100644
index 00000000000..15209522472
--- /dev/null
+++ b/admin/spec/components/solidus_admin/ui/search_panel/component_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe SolidusAdmin::UI::SearchPanel::Component, type: :component do
+ it "renders the overview preview" do
+ render_preview(:overview)
+ end
+end
diff --git a/admin/spec/features/order_spec.rb b/admin/spec/features/order_spec.rb
index 95dff2483ba..d2a41852e29 100644
--- a/admin/spec/features/order_spec.rb
+++ b/admin/spec/features/order_spec.rb
@@ -15,7 +15,7 @@
expect(page).to have_content("Order R123456789")
- search_field = find("[data-#{SolidusAdmin::Orders::Cart::Component.stimulus_id}-target='searchField']")
+ search_field = find("[data-#{SolidusAdmin::UI::SearchPanel::Component.stimulus_id}-target='searchField']")
search_field.set "another"
expect(page).not_to have_content("Just a product")