11defmodule PeekAppSDK.UI.Odyssey.ProductPicker do
22 @ moduledoc """
33 A live component for selecting products with a toggle between "All" and "Specific".
4- When "Specific" is selected, displays checkboxes for each product.
54
6- Integrates with forms via a hidden input field and a JS hook.
5+ When "Specific" is selected, displays a searchable list where clicking a row
6+ toggles selection. Selected state is managed server-side (no JS checkbox hacks).
7+
8+ Integrates with forms via a hidden input field. The parent form is notified of
9+ changes via the global `trigger-input` phx event (wired up by `addOdysseyGlobalEvents`).
710
811 Products must conform to `%{id: string, name: string, color: string}` (atom keys).
912 Callers are responsible for mapping their data to this shape before passing it in.
@@ -27,16 +30,22 @@ defmodule PeekAppSDK.UI.Odyssey.ProductPicker do
2730
2831 @ impl true
2932 def mount ( socket ) do
30- { :ok , socket }
33+ { :ok ,
34+ socket
35+ |> assign ( :search , "" )
36+ |> assign ( :filtered_products , [ ] ) }
3137 end
3238
3339 @ impl true
3440 def update ( assigns , socket ) do
3541 selected_ids = resolve_selected_ids ( assigns )
36- socket = assign ( socket , Map . put ( assigns , :selected_ids , selected_ids ) )
42+ search = Map . get ( socket . assigns , :search , "" )
3743
3844 socket =
3945 socket
46+ |> assign ( assigns )
47+ |> assign ( :selected_ids , selected_ids )
48+ |> assign ( :filtered_products , filter_products ( assigns [ :products ] || [ ] , search ) )
4049 |> assign_new ( :apply_to_mode , fn -> determine_mode ( selected_ids ) end )
4150
4251 { :ok , socket }
@@ -71,18 +80,25 @@ defmodule PeekAppSDK.UI.Odyssey.ProductPicker do
7180 defp determine_mode ( [ ] ) , do: "all"
7281 defp determine_mode ( _ ) , do: "specific"
7382
83+ defp filter_products ( products , "" ) , do: products
84+
85+ defp filter_products ( products , search ) do
86+ query = String . downcase ( search )
87+ Enum . filter ( products , & String . contains? ( String . downcase ( & 1 . name ) , query ) )
88+ end
89+
7490 @ impl true
7591 def render ( assigns ) do
7692 ~H"""
77- < div id = { @ id } phx-hook = " OdysseyProductPicker " data-integration = "product-picker " >
93+ < div id = { @ id } data-integration = "product-picker " >
7894 < input
7995 type = "hidden "
8096 name = { @ field . name }
8197 value = { encode_selected_products ( @ selected_ids ) }
8298 id = { "#{ @ id } _hidden_field" }
8399 />
84100
85- < div class = "space-y-4 " >
101+ < div class = "space-y-3 " >
86102 < . odyssey_toggle_button
87103 options = { [
88104 % { value: "all" , label: @ all_label } ,
@@ -95,29 +111,79 @@ defmodule PeekAppSDK.UI.Odyssey.ProductPicker do
95111 disabled = { @ disabled }
96112 />
97113
98- < div
99- :if = { @ apply_to_mode == "specific" }
100- class = "space-y-2 pl-4 max-h-64 overflow-y-auto "
101- phx-update = "ignore "
102- id = { "#{ @ id } _checkboxes" }
103- >
104- < div :for = { product <- @ products } class = "flex items-center gap-3 " >
105- < input
106- type = "checkbox "
107- id = { "#{ @ id } _product_#{ product . id } " }
108- checked = { product . id in @ selected_ids }
109- data-product-id = { product . id }
110- disabled = { @ disabled }
111- class = "checkbox checkbox-sm product-picker-checkbox "
112- />
113- < div
114- class = "w-3 h-3 rounded-sm flex-shrink-0 "
115- style = { "background-color: #{ product . color } " }
116- >
114+ < div :if = { @ apply_to_mode == "specific" } class = "rounded-xl border border-zinc-200 bg-white shadow-sm overflow-hidden " >
115+ < div class = "px-3 pt-3 pb-2 border-b border-zinc-100 " >
116+ < div class = "flex items-center gap-2 rounded-md border border-zinc-300 bg-zinc-50 px-3 py-2 text-sm text-zinc-600 " >
117+ < svg
118+ class = "w-4 h-4 opacity-70 shrink-0 "
119+ viewBox = "0 0 20 20 "
120+ fill = "currentColor "
121+ aria-hidden = "true "
122+ >
123+ < path
124+ fill-rule = "evenodd "
125+ d = "M12.9 14.32a8 8 0 1 1 1.414-1.414l3.39 3.39a1 1 0 0 1-1.414 1.414l-3.39-3.39ZM14 8a6 6 0 1 0-12 0 6 6 0 0 0 12 0Z "
126+ clip-rule = "evenodd "
127+ />
128+ </ svg >
129+ < input
130+ type = "text "
131+ class = "flex-1 bg-transparent outline-none text-sm "
132+ placeholder = { @ search_placeholder }
133+ value = { @ search }
134+ phx-keyup = "search "
135+ phx-target = { @ myself }
136+ disabled = { @ disabled }
137+ />
138+ < span
139+ :if = { length ( @ selected_ids ) > 0 }
140+ class = "text-xs font-medium text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full shrink-0 "
141+ >
142+ { length ( @ selected_ids ) } selected
143+ </ span >
117144 </ div >
118- < label for = { "#{ @ id } _product_#{ product . id } " } class = "text-sm text-gray-700 cursor-pointer " >
119- { product . name }
120- </ label >
145+ </ div >
146+
147+ < div class = "max-h-64 overflow-y-auto py-1 " >
148+ < button
149+ :for = { product <- @ filtered_products }
150+ type = "button "
151+ class = { [
152+ "flex w-full items-center gap-3 px-4 py-2.5 text-sm text-left transition-colors" ,
153+ product . id in @ selected_ids && "bg-blue-50" ,
154+ product . id not in @ selected_ids && "hover:bg-zinc-50" ,
155+ @ disabled && "pointer-events-none opacity-60"
156+ ] }
157+ phx-click = "toggle_product "
158+ phx-value-id = { product . id }
159+ phx-target = { @ myself }
160+ >
161+ < div
162+ class = "w-3 h-3 rounded-sm shrink-0 "
163+ style = { "background-color: #{ product . color } " }
164+ />
165+ < span class = "flex-1 text-zinc-700 " > { product . name } </ span >
166+ < svg
167+ :if = { product . id in @ selected_ids }
168+ class = "w-4 h-4 text-blue-600 shrink-0 "
169+ viewBox = "0 0 20 20 "
170+ fill = "currentColor "
171+ aria-hidden = "true "
172+ >
173+ < path
174+ fill-rule = "evenodd "
175+ d = "M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z "
176+ clip-rule = "evenodd "
177+ />
178+ </ svg >
179+ </ button >
180+
181+ < p
182+ :if = { @ filtered_products == [ ] && @ search != "" }
183+ class = "px-4 py-6 text-sm text-zinc-400 text-center "
184+ >
185+ No products match your search.
186+ </ p >
121187 </ div >
122188 </ div >
123189 </ div >
@@ -126,21 +192,47 @@ defmodule PeekAppSDK.UI.Odyssey.ProductPicker do
126192 end
127193
128194 @ impl true
129- def handle_event ( "toggle_apply_to" , % { "value" => mode } , socket ) do
195+ def handle_event ( "toggle_apply_to" , % { "value" => "all" } , socket ) do
196+ socket =
197+ socket
198+ |> assign ( :apply_to_mode , "all" )
199+ |> assign ( :selected_ids , [ ] )
200+ |> assign ( :search , "" )
201+ |> assign ( :filtered_products , socket . assigns . products )
202+ |> push_event ( "trigger-input" , % { field_id: "#{ socket . assigns . id } _hidden_field" } )
203+
204+ { :noreply , socket }
205+ end
206+
207+ def handle_event ( "toggle_apply_to" , % { "value" => "specific" } , socket ) do
208+ socket =
209+ socket
210+ |> assign ( :apply_to_mode , "specific" )
211+ |> assign ( :search , "" )
212+ |> assign ( :filtered_products , socket . assigns . products )
213+
214+ { :noreply , socket }
215+ end
216+
217+ def handle_event ( "toggle_product" , % { "id" => id } , socket ) do
130218 selected_ids =
131- case mode do
132- "all" -> [ ]
133- "specific" -> socket . assigns . selected_ids
134- end
219+ if id in socket . assigns . selected_ids ,
220+ do: Enum . reject ( socket . assigns . selected_ids , & ( & 1 == id ) ) ,
221+ else: [ id | socket . assigns . selected_ids ] |> Enum . reverse ( )
135222
136223 socket =
137224 socket
138- |> assign ( :apply_to_mode , mode )
139225 |> assign ( :selected_ids , selected_ids )
140- |> push_event ( "update-product-selection" , % {
141- field_id: "#{ socket . assigns . id } _hidden_field" ,
142- value: encode_selected_products ( selected_ids )
143- } )
226+ |> push_event ( "trigger-input" , % { field_id: "#{ socket . assigns . id } _hidden_field" } )
227+
228+ { :noreply , socket }
229+ end
230+
231+ def handle_event ( "search" , % { "value" => value } , socket ) do
232+ socket =
233+ socket
234+ |> assign ( :search , value )
235+ |> assign ( :filtered_products , filter_products ( socket . assigns . products , value ) )
144236
145237 { :noreply , socket }
146238 end
@@ -169,12 +261,12 @@ defmodule PeekAppSDK.UI.Odyssey.ProductPicker do
169261 """
170262 attr :field , :any , required: true , doc: "a Phoenix.HTML.FormField struct"
171263 attr :id , :string , doc: "component id, defaults to form_field_product_picker"
172-
173264 attr :products , :list , required: true , doc: "list of %{id: string, name: string, color: string} maps"
174265 attr :selected_ids , :list , doc: "list of pre-selected product IDs (auto-extracted from field value when omitted)"
175266 attr :all_label , :string , default: "All Products" , doc: "label for the 'all' toggle option"
176267 attr :specific_label , :string , default: "Specific Products" , doc: "label for the 'specific' toggle option"
177268 attr :label , :string , required: false , doc: "label for the toggle button"
269+ attr :search_placeholder , :string , default: "Search..." , doc: "placeholder text for the search input"
178270 attr :disabled , :boolean , default: false , doc: "whether the picker is disabled"
179271
180272 def odyssey_product_picker ( assigns ) do
0 commit comments