@@ -5,23 +5,37 @@ defmodule PeekAppSDK.UI.Odyssey.Select do
55 Each item must have an `id` and `name` field. Color is optional and can be
66 specified via the `color_field` assign (defaults to `:color_hex`).
77
8+ Supports two modes:
9+ - **Standalone**: fires an event via `on_select` when an item is selected
10+ - **Form-integrated**: when a `field` is provided, manages a hidden input for form binding.
11+ Supports single and multiple selection via the `multiple` attr.
12+
813 ## Examples
914
15+ Standalone:
16+
1017 <.odyssey_select
1118 id="activity-picker"
1219 items={@activities}
1320 on_select={:activity_selected}
1421 title="Select Activity"
1522 />
1623
24+ Form-integrated (single select):
25+
26+ <.odyssey_select
27+ field={@form[:category_id]}
28+ items={@categories}
29+ title="Select Category"
30+ />
31+
32+ Form-integrated (multiple select):
33+
1734 <.odyssey_select
18- id="ticket-picker"
19- items={@tickets}
20- excluded_ids={@used_ids}
21- on_select={:ticket_selected}
22- context={@index}
23- color_field={:colorHex}
24- title="+ Add Another Ticket"
35+ field={@form[:tag_ids]}
36+ items={@tags}
37+ title="Select Tags"
38+ multiple={true}
2539 />
2640 """
2741
@@ -37,13 +51,25 @@ defmodule PeekAppSDK.UI.Odyssey.Select do
3751
3852 @ impl true
3953 def update ( assigns , socket ) do
54+ socket = assign ( socket , assigns )
55+
56+ socket =
57+ if socket . assigns [ :field ] do
58+ selected_ids = resolve_selected_ids ( socket . assigns )
59+ assign ( socket , :selected_ids , selected_ids )
60+ else
61+ assign_new ( socket , :selected_ids , fn -> [ ] end )
62+ end
63+
4064 { :ok ,
4165 socket
42- |> assign ( assigns )
4366 |> assign_new ( :title , fn -> "Select Item" end )
4467 |> assign_new ( :excluded_ids , fn -> [ ] end )
4568 |> assign_new ( :color_field , fn -> :color_hex end )
46- |> assign_new ( :context , fn -> nil end ) }
69+ |> assign_new ( :context , fn -> nil end )
70+ |> assign_new ( :field , fn -> nil end )
71+ |> assign_new ( :multiple , fn -> false end )
72+ |> assign_new ( :on_select , fn -> nil end ) }
4773 end
4874
4975 @ impl true
@@ -56,14 +82,22 @@ defmodule PeekAppSDK.UI.Odyssey.Select do
5682 phx-target = { @ myself }
5783 phx-hook = "OdysseySelect "
5884 >
85+ < input
86+ :if = { @ field }
87+ type = "hidden "
88+ id = { "#{ @ id } _hidden_field" }
89+ name = { @ field . name }
90+ value = { encode_selected ( @ selected_ids ) }
91+ />
92+
5993 < button
6094 type = "button "
6195 class = "inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium border-zinc-300 bg-white hover:bg-zinc-50 cursor-pointer "
6296 phx-click = "toggle "
6397 phx-target = { @ myself }
6498 aria-expanded = { @ open }
6599 >
66- < span > { @ title } </ span >
100+ < span > { button_text ( @ selected_ids , @ items , @ title , @ multiple ) } </ span >
67101 < svg class = "w-4 h-4 text-zinc-500 " viewBox = "0 0 20 20 " fill = "currentColor " aria-hidden = "true " >
68102 < path
69103 fill-rule = "evenodd "
@@ -102,7 +136,10 @@ defmodule PeekAppSDK.UI.Odyssey.Select do
102136 < button
103137 :for = { item <- filtered_items ( @ items , @ search , @ excluded_ids ) }
104138 type = "button "
105- class = "text-left flex w-full items-center gap-2 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-50 cursor-pointer "
139+ class = { [
140+ "text-left flex w-full items-center gap-2 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-50 cursor-pointer" ,
141+ selected? ( item , @ selected_ids ) && "bg-zinc-50 font-medium"
142+ ] }
106143 phx-click = "select "
107144 phx-value-id = { item . id }
108145 phx-target = { @ myself }
@@ -113,7 +150,19 @@ defmodule PeekAppSDK.UI.Odyssey.Select do
113150 aria-hidden = "true "
114151 style = { "background-color: #{ get_color ( item , @ color_field ) } " }
115152 />
116- < span > { item . name } </ span >
153+ < span class = "flex-1 " > { item . name } </ span >
154+ < svg
155+ :if = { selected? ( item , @ selected_ids ) }
156+ class = "w-4 h-4 text-blue-600 "
157+ viewBox = "0 0 20 20 "
158+ fill = "currentColor "
159+ >
160+ < path
161+ fill-rule = "evenodd "
162+ 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 "
163+ clip-rule = "evenodd "
164+ />
165+ </ svg >
117166 </ button >
118167 </ div >
119168 </ div >
@@ -135,13 +184,85 @@ defmodule PeekAppSDK.UI.Odyssey.Select do
135184 end
136185
137186 def handle_event ( "select" , % { "id" => id } , socket ) do
187+ if socket . assigns . field do
188+ handle_form_select ( id , socket )
189+ else
190+ handle_standalone_select ( id , socket )
191+ end
192+ end
193+
194+ defp handle_standalone_select ( id , socket ) do
138195 % { on_select: on_select , context: context } = socket . assigns
139196 send ( self ( ) , { on_select , context , id } )
140197 { :noreply , socket |> assign ( :open , false ) |> assign ( :search , "" ) }
141198 end
142199
200+ defp handle_form_select ( id , socket ) do
201+ id_str = to_string ( id )
202+
203+ selected_ids =
204+ if socket . assigns . multiple do
205+ if selected_by_id? ( id_str , socket . assigns . selected_ids ) ,
206+ do: Enum . reject ( socket . assigns . selected_ids , & ( to_string ( & 1 ) == id_str ) ) ,
207+ else: Enum . reverse ( [ id_str | Enum . reverse ( socket . assigns . selected_ids ) ] )
208+ else
209+ [ id_str ]
210+ end
211+
212+ socket =
213+ socket
214+ |> assign ( :selected_ids , selected_ids )
215+ |> push_event ( "trigger-input" , % { field_id: "#{ socket . assigns . id } _hidden_field" } )
216+
217+ socket =
218+ if socket . assigns . multiple ,
219+ do: socket ,
220+ else: socket |> assign ( :open , false ) |> assign ( :search , "" )
221+
222+ { :noreply , socket }
223+ end
224+
143225 defp get_color ( item , field ) , do: Map . get ( item , field )
144226
227+ defp selected? ( item , selected_ids ) ,
228+ do: selected_by_id? ( to_string ( item . id ) , selected_ids )
229+
230+ defp selected_by_id? ( id_str , selected_ids ) ,
231+ do: id_str in Enum . map ( selected_ids , & to_string / 1 )
232+
233+ defp button_text ( [ ] , _items , title , _multiple ) , do: title
234+ defp button_text ( _ids , _items , title , true ) , do: title
235+
236+ defp button_text ( [ id | _ ] , items , title , false ) do
237+ case Enum . find ( items , & ( to_string ( & 1 . id ) == to_string ( id ) ) ) do
238+ nil -> title
239+ item -> item . name
240+ end
241+ end
242+
243+ defp encode_selected ( [ ] ) , do: ""
244+ defp encode_selected ( ids ) , do: Enum . join ( ids , "," )
245+
246+ defp resolve_selected_ids ( % { selected_ids: ids } ) when is_list ( ids ) , do: ids
247+
248+ defp resolve_selected_ids ( % { field: field , multiple: true } ) do
249+ case field . value do
250+ nil -> [ ]
251+ "" -> [ ]
252+ ids when is_list ( ids ) -> Enum . map ( ids , & to_string / 1 )
253+ ids when is_binary ( ids ) -> String . split ( ids , "," , trim: true )
254+ id -> [ to_string ( id ) ]
255+ end
256+ end
257+
258+ defp resolve_selected_ids ( % { field: field } ) do
259+ case field . value do
260+ nil -> [ ]
261+ "" -> [ ]
262+ id -> [ to_string ( id ) ]
263+ end
264+ end
265+
145266 defp filtered_items ( items , search , excluded_ids ) do
146267 excluded_ids = Enum . map ( excluded_ids , & to_string / 1 )
147268
@@ -159,24 +280,75 @@ defmodule PeekAppSDK.UI.Odyssey.Select do
159280 @ doc """
160281 Renders an odyssey_select dropdown component.
161282
283+ Supports two modes:
284+ - **Standalone**: fires an event via `on_select` when an item is selected
285+ - **Form-integrated**: when a `field` is provided, manages a hidden input for form binding
286+
162287 ## Examples
163288
289+ Standalone:
290+
164291 <.odyssey_select
165292 id="activity-picker"
166293 items={@activities}
167294 on_select={:activity_selected}
168295 title="Select Activity"
169296 />
297+
298+ Form-integrated (single select):
299+
300+ <.odyssey_select
301+ field={@form[:category_id]}
302+ items={@categories}
303+ title="Select Category"
304+ />
305+
306+ Form-integrated (multiple select):
307+
308+ <.odyssey_select
309+ field={@form[:tag_ids]}
310+ items={@tags}
311+ title="Select Tags"
312+ multiple={true}
313+ />
170314 """
171- attr :id , :string , required: true
315+ attr :id , :string , required: false
172316 attr :items , :list , required: true , doc: "list of items with :id and :name fields"
173- attr :on_select , :atom , required: true , doc: "message atom sent when item is selected"
317+ attr :on_select , :atom , required: false , doc: "message atom sent when item is selected (standalone mode) "
174318 attr :title , :string , default: "Select Item" , doc: "button text"
175319 attr :excluded_ids , :list , default: [ ] , doc: "list of ids to exclude"
176320 attr :color_field , :atom , default: :color_hex , doc: "atom for the color field on items"
177321 attr :context , :any , default: nil , doc: "additional context included in the message"
178322 attr :label , :string , required: false , doc: "optional label to wrap the select in a fieldset"
323+ attr :field , Phoenix.HTML.FormField , required: false , doc: "form field for form-integrated mode"
324+ attr :multiple , :boolean , default: false , doc: "allow multiple selections (form mode only)"
325+ attr :selected_ids , :list , required: false , doc: "override for pre-selected IDs (form mode only)"
326+
327+ # Form-integrated mode
328+ def odyssey_select ( % { field: _field } = assigns ) do
329+ assigns =
330+ assigns
331+ |> assign_new ( :id , fn % { field: field } ->
332+ "odyssey_select_#{ field . form . name } _#{ field . field } "
333+ end )
334+ |> assign_new ( :label , fn -> nil end )
335+ |> assign ( :module , __MODULE__ )
336+
337+ ~H"""
338+ <%= if @ label do %>
339+ < fieldset class = "fieldset mb-2 " >
340+ < div >
341+ < span class = "label mb-1 block " > { @ label } </ span >
342+ < . live_component { assigns } />
343+ </ div >
344+ </ fieldset >
345+ <% else %>
346+ < . live_component { assigns } />
347+ <% end %>
348+ """
349+ end
179350
351+ # Standalone mode
180352 def odyssey_select ( assigns ) do
181353 assigns =
182354 assigns
0 commit comments