Current section

45 Versions

Jump to

Compare versions

6 files changed
+129 additions
-80 deletions
  @@ -1,6 +1,6 @@
1 1 {<<"links">>,[{<<"GitHub">>,<<"https://github.com/maxmarcon/live_select">>}]}.
2 2 {<<"name">>,<<"live_select">>}.
3 - {<<"version">>,<<"1.4.4">>}.
3 + {<<"version">>,<<"1.5.0">>}.
4 4 {<<"description">>,<<"Dynamic (multi)selection field for LiveView">>}.
5 5 {<<"elixir">>,<<"~> 1.14">>}.
6 6 {<<"app">>,<<"live_select">>}.
  @@ -6,12 +6,12 @@ defmodule LiveSelect do
6 6 @moduledoc ~S"""
7 7 The `LiveSelect` component is rendered by calling the `live_select/1` function and passing it a form field.
8 8 LiveSelect creates a text input field in which the user can type text, and hidden input field(s) that will contain the value of the selected option(s).
9 -
9 +
10 10 Whenever the user types something in the text input, LiveSelect triggers a `live_select_change` event for your LiveView or LiveComponent.
11 - The message has a `text` parameter containing the current text entered by the user, as well as `id` and `field` parameters with the id of the
11 + The message has a `text` parameter containing the current text entered by the user, as well as `id` and `field` parameters with the id of the
12 12 LiveSelect component and the name of the LiveSelect form field, respectively.
13 13 Your job is to handle the event, retrieve the list of selectable options and then call `Phoenix.LiveView.send_update/3`
14 - to send the list of options to LiveSelect. See the "Examples" section below for details, and check out the
14 + to send the list of options to LiveSelect. See the "Examples" section below for details, and check out the
15 15 [cheatsheet](https://hexdocs.pm/live_select/cheatsheet.html) for some useful tips.
16 16
17 17 Selection can happen either using the keyboard, by navigating the options with the arrow keys and then pressing enter, or by
  @@ -23,12 +23,12 @@ defmodule LiveSelect do
23 23 In single mode, if the configuration option `allow_clear` is set, the user can manually clear the selection by clicking on the `x` button on the input field.
24 24 In tags mode, single tags can be removed by clicking on them.
25 25
26 - ## Single mode
26 + ## Single mode
27 27
28 28 <img alt="demo" src="https://raw.githubusercontent.com/maxmarcon/live_select/main/priv/static/images/demo_single.gif" width="300" />
29 29
30 30 ## Tags mode
31 -
31 +
32 32 <img alt="demo" src="https://raw.githubusercontent.com/maxmarcon/live_select/main/priv/static/images/demo_tags.gif" width="300" />
33 33
34 34 When `:tags` mode is enabled `LiveSelect` allows the user to select multiple entries. The entries will be visible above the text input field as removable tags.
  @@ -46,7 +46,7 @@ defmodule LiveSelect do
46 46
47 47 * _atoms, strings or numbers_: In this case, each element will be both label and value for the option
48 48 * _tuples_: `{label, value}` corresponding to label and value for the option
49 - * _maps_: `%{label: label, value: value}` or `%{value: value}`
49 + * _maps_: `%{label: label, value: value}` or `%{value: value}`
50 50 * _keywords_: `[label: label, value: value]` or `[value: value]`
51 51
52 52 In the case of maps and keywords, if only `value` is specified, it will be used as both value and label for the option.
  @@ -61,23 +61,23 @@ defmodule LiveSelect do
61 61
62 62 Note that the option values, if they are not strings, will be JSON-encoded. Your LiveView will receive this JSON-encoded version in the `phx-change` and `phx-submit` events.
63 63
64 - ## Styling
65 -
64 + ## Styling
65 +
66 66 `LiveSelect` supports 3 styling modes:
67 67
68 68 * `tailwind`: uses standard tailwind utility classes (the default)
69 69 * `daisyui`: uses [daisyUI](https://daisyui.com/) classes.
70 70 * `none`: no styling at all.
71 71
72 - Please see [the styling section](styling.md) for details
72 + Please see [the styling section](styling.md) for details
73 73
74 74 ## Alternative tag labels
75 -
76 - Sometimes, in `:tags` mode, you might want to use alternative labels for the tags. For example, you might want the labels in the tags to be shorter
75 +
76 + Sometimes, in `:tags` mode, you might want to use alternative labels for the tags. For example, you might want the labels in the tags to be shorter
77 77 in order to save space. You can do this by specifying an additional `tag_label` key when passing options as map or keywords. For example, passing these options:
78 78
79 79 ```
80 - [%{label: "New York", value: "NY", tag_label: "NY"}, %{label: "Barcelona", value: "BCN", tag_label: "BCN"}]
80 + [%{label: "New York", value: "NY", tag_label: "NY"}, %{label: "Barcelona", value: "BCN", tag_label: "BCN"}]
81 81 ```
82 82
83 83 will result in "New York" and "Barcelona" being used for the options in the dropdown, while "NY" and "BCN" will be used for the tags (and the values).
  @@ -93,11 +93,14 @@ defmodule LiveSelect do
93 93 Now, whenever the selection contains "New York", the option will stick and the user won't be able to remove it.
94 94
95 95 ## Slots
96 -
96 +
97 97 You can control how your options and tags are rendered by using the `:option` and `:tag` slots.
98 + Both slots will be passed an option as argument. In the case of the `:option` slot, the option will have an
99 + extra boolean field `:selected`, which will be set to `true` if the option has been selected by the user.
100 +
98 101 Let's say you want to show some fancy icons next to each option in the dropdown and the tags:
99 102
100 - ```elixir
103 + ```elixir
101 104 <.live_select
102 105 field={@form[:city_search]}
103 106 phx-target={@myself}
  @@ -115,7 +118,7 @@ defmodule LiveSelect do
115 118 ```
116 119
117 120 Here's the result:
118 -
121 +
119 122 <img alt="slots" src="https://raw.githubusercontent.com/maxmarcon/live_select/main/priv/static/images/slots.png" width="200" />
120 123
121 124 ## Controlling the selection programmatically
  @@ -127,8 +130,8 @@ defmodule LiveSelect do
127 130 send_update(LiveSelect.Component, id: live_select_id, value: new_selection)
128 131 ```
129 132
130 - `new_selection` must be a single element in `:single` mode, a list in `:tags` mode. If it's `nil`, the selection will be cleared.
131 - After updating the selection, `LiveSelect` will trigger a change event in the form.
133 + `new_selection` must be a single element in `:single` mode, a list in `:tags` mode. If it's `nil`, the selection will be cleared.
134 + After updating the selection, `LiveSelect` will trigger a change event in the form.
132 135
133 136 To set a custom id for the component to use with `Phoenix.LiveView.send_update/3`, you can pass the `id` assign to `live_select/1`.
134 137
  @@ -148,23 +151,23 @@ defmodule LiveSelect do
148 151 _Template:_
149 152 ```
150 153 <.form for={@form} phx-change="change">
151 - <.live_select field={@form[:city_search]} />
154 + <.live_select field={@form[:city_search]} />
152 155 </.form>
153 156 ```
154 -
157 +
155 158 > #### Forms implemented in LiveComponents {: .warning}
156 - >
159 + >
157 160 > If your form is implemented in a LiveComponent and not in a LiveView, you have to add the `phx-target` attribute
158 161 > when rendering LiveSelect:
159 162 >
160 163 > ```elixir
161 164 > <.live_select field={@form[:city_search]} phx-target={@myself} />
162 - > ```
163 -
165 + > ```
166 +
164 167 _LiveView or LiveComponent that is the target of the form's events:_
165 168 ```
166 169 @impl true
167 - def handle_event("live_select_change", %{"text" => text, "id" => live_select_id}, socket) do
170 + def handle_event("live_select_change", %{"text" => text, "id" => live_select_id}, socket) do
168 171 cities = City.search(text)
169 172 # cities could be:
170 173 # [ {"city name 1", [lat_1, long_1]}, {"city name 2", [lat_2, long_2]}, ... ]
  @@ -173,13 +176,13 @@ defmodule LiveSelect do
173 176 # [ "city name 1", "city name 2", ... ]
174 177 #
175 178 # or:
176 - # [ [label: "city name 1", value: [lat_1, long_1]], [label: "city name 2", value: [lat_2, long_2]], ... ]
179 + # [ [label: "city name 1", value: [lat_1, long_1]], [label: "city name 2", value: [lat_2, long_2]], ... ]
177 180 #
178 181 # or even:
179 182 # ["city name 1": [lat_1, long_1], "city name 2": [lat_2, long_2]]
180 183
181 184 send_update(LiveSelect.Component, id: live_select_id, options: cities)
182 -
185 +
183 186 {:noreply, socket}
184 187 end
185 188
  @@ -192,7 +195,7 @@ defmodule LiveSelect do
192 195 IO.puts("You selected city #{city_name} located at: #{city_coords}")
193 196
194 197 {:noreply, socket}
195 - end
198 + end
196 199 ```
197 200
198 201 ### Tags mode
  @@ -203,7 +206,7 @@ defmodule LiveSelect do
203 206 _Template:_
204 207 ```
205 208 <.form for={@form} phx-change="change">
206 - <.live_select field={@form[:city_search]} mode={:tags} />
209 + <.live_select field={@form[:city_search]} mode={:tags} />
207 210 </.form>
208 211 ```
209 212
  @@ -216,17 +219,22 @@ defmodule LiveSelect do
216 219 socket
217 220 ) do
218 221 # list_of_coords will contain the list of the JSON-encoded coordinates of the selected cities, for example:
219 - # ["[-46.565,-23.69389]", "[-48.27722,-18.91861]"]
222 + # ["[-46.565,-23.69389]", "[-48.27722,-18.91861]"]
220 223
221 224 IO.puts("You selected cities located at: #{list_of_coords}")
222 225
223 226 {:noreply, socket}
224 - end
227 + end
225 228 ```
229 + ### Quick tags mode
226 230
227 - ### Multiple LiveSelect inputs in the same LiveView
231 + Quick tags work like tags except that the dropdown stays open after selection. This allows the user to add elements to the selection
232 + in quick succession. Also, the user can deselect already selected options via the dropdown. The dropdown closes when the LiveSelect component
233 + loses focus.
228 234
229 - If you have multiple LiveSelect inputs in the same LiveView, you can distinguish them based on the field id.
235 + ### Multiple LiveSelect inputs in the same LiveView
236 +
237 + If you have multiple LiveSelect inputs in the same LiveView, you can distinguish them based on the field id.
230 238 For example:
231 239
232 240 _Template:_
  @@ -255,10 +263,10 @@ defmodule LiveSelect do
255 263
256 264 ## Using LiveSelect with associations and embeds
257 265
258 - LiveSelect can also be used to display and select associations or embeds without too much effort.
259 - Let's say you have the following schemas:
260 -
261 - ```
266 + LiveSelect can also be used to display and select associations or embeds without too much effort.
267 + Let's say you have the following schemas:
268 +
269 + ```
262 270 defmodule City do
263 271 @moduledoc false
264 272
  @@ -292,7 +300,7 @@ defmodule LiveSelect do
292 300 end
293 301 end
294 302 ```
295 -
303 +
296 304 Each city has a name and an array with coordinates - we want `LiveSelect` to display the name as label in the dropdown and in the tags, but we want
297 305 the entire data structure (name + coordinates) to be sent to the server when the user selects.
298 306
  @@ -329,20 +337,20 @@ defmodule LiveSelect do
329 337 {:noreply, socket}
330 338 end
331 339 ```
332 -
340 +
333 341 > #### IMPORTANT: the output of the `value_mapper/1` function should be JSON-encodable {: .warning}
334 342
335 - Finally, in order to take care of (2) you need to decode the JSON-encoded list of options that's coming from the client before you can
343 + Finally, in order to take care of (2) you need to decode the JSON-encoded list of options that's coming from the client before you can
336 344 cast them to create a changeset. To do so, `LiveSelect` offers a convenience function called `LiveSelect.decode/1`:
337 345 ```
338 346 def handle_event("change", params, socket) do
339 - # decode will JSON-decode the value in city_search, handling the type of selection
347 + # decode will JSON-decode the value in city_search, handling the type of selection
340 348 # and taking care of special values such as "" and nil
341 349 params = update_in(params, ~w(city_search_form city_search), &LiveSelect.decode/1)
342 -
350 +
343 351 # now we can cast the params:
344 352 changeset = CitySearchForm.changeset(params)
345 -
353 +
346 354 {:noreply, assign(socket, form: to_form(changeset))}
347 355 end
348 356 ```
  @@ -352,12 +360,12 @@ defmodule LiveSelect do
352 360
353 361 @doc ~S"""
354 362 Renders a `LiveSelect` input in a form.
355 -
363 +
356 364 [INSERT LVATTRDOCS]
357 365
358 366 ## Styling attributes
359 367
360 - * See [the styling section](styling.md) for details
368 + * See [the styling section](styling.md) for details
361 369 """
362 370 @doc type: :component
363 371
  @@ -370,15 +378,16 @@ defmodule LiveSelect do
370 378 ~S(an id to assign to the component. If none is provided, `#{form_name}_#{field}_live_select_component` will be used)
371 379
372 380 attr :mode, :atom,
373 - values: [:single, :tags],
381 + values: [:single, :tags, :quick_tags],
374 382 default: Component.default_opts()[:mode],
375 - doc: "either `:single` (for single selection), or `:tags` (for multiple selection using tags)"
383 + doc:
384 + "either `:single` (for single selection), `:tags` (for multiple selection using tags), or :quick_tags (multiple selection but tags can be selected/deselected in quick succession)"
376 385
377 386 attr :options, :list,
378 387 doc:
379 388 ~s(initial available options to select from. Note that, after the initial rendering of the component, options can only be updated using `Phoenix.LiveView.send_update/3` - See the "Options" section for details)
380 389
381 - attr :value, :any, doc: "used to manually set a selection - overrides any values from the form.
390 + attr :value, :any, doc: "used to manually set a selection - overrides any values from the form.
382 391 Must be a single element in `:single` mode, or a list of elements in `:tags` mode."
383 392
384 393 attr :max_selectable, :integer,
  @@ -479,27 +488,27 @@ defmodule LiveSelect do
479 488
480 489 @doc ~S"""
481 490 Decodes the selection from the client. This has to be used when the values in the selection aren't simple integers or strings.
482 -
483 - Let's say you receive your params in the variable `params`, and your `LiveSelect` field is called `my_field` and belongs to the form `my_form`. Then you should
491 +
492 + Let's say you receive your params in the variable `params`, and your `LiveSelect` field is called `my_field` and belongs to the form `my_form`. Then you should
484 493 decode like this:
485 494
486 495 ```
487 496 params = update_in(params, ~w(my_form my_field), &LiveSelect.decode/1)
488 497 ```
489 -
490 - ## Examples:
498 +
499 + ## Examples:
491 500
492 501 iex> decode(nil)
493 502 []
494 -
503 +
495 504 iex> decode("")
496 505 nil
497 -
506 +
498 507 iex> decode("{\"name\":\"Berlin\",\"pos\":[13.41053,52.52437]}")
499 508 %{"name" => "Berlin","pos" => [13.41053,52.52437]}
500 -
509 +
501 510 iex> decode(["{\"name\":\"New York City\",\"pos\":[-74.00597,40.71427]}","{\"name\":\"Stockholm\",\"pos\":[18.06871,59.32938]}"])
502 - [%{"name" => "New York City","pos" => [-74.00597,40.71427]}, %{"name" => "Stockholm","pos" => [18.06871,59.32938]}]
511 + [%{"name" => "New York City","pos" => [-74.00597,40.71427]}, %{"name" => "Stockholm","pos" => [18.06871,59.32938]}]
503 512 """
504 513 def decode(selection) do
505 514 case selection do
  @@ -78,7 +78,7 @@ defmodule LiveSelect.Component do
78 78 none: []
79 79 ]
80 80
81 - @modes ~w(single tags)a
81 + @modes ~w(single tags quick_tags)a
82 82
83 83 @impl true
84 84 def mount(socket) do
  @@ -429,25 +429,43 @@ defmodule LiveSelect.Component do
429 429
430 430 defp maybe_select(%{assigns: %{active_option: -1}} = socket, _extra_params), do: socket
431 431
432 + defp maybe_select(
433 + %{assigns: %{active_option: active_option, options: options, selection: selection}} =
434 + socket,
435 + extra_params
436 + )
437 + when active_option >= 0 do
438 + option = Enum.at(options, active_option)
439 +
440 + if already_selected?(option, selection) do
441 + pos = get_selection_index(option, selection)
442 + unselect(socket, pos)
443 + else
444 + select(socket, option, extra_params)
445 + end
446 + end
447 +
432 448 defp maybe_select(socket, extra_params) do
433 449 select(socket, Enum.at(socket.assigns.options, socket.assigns.active_option), extra_params)
434 450 end
435 451
452 + defp get_selection_index(option, selection) do
453 + Enum.find_index(selection, fn %{label: label} -> label == option.label end)
454 + end
455 +
436 456 defp select(socket, selected, extra_params) do
437 457 selection =
438 - case socket.assigns.mode do
439 - :tags ->
440 - socket.assigns.selection ++ [selected]
441 -
442 - _ ->
443 - [selected]
458 + if socket.assigns.mode in [:tags, :quick_tags] do
459 + socket.assigns.selection ++ [selected]
460 + else
461 + [selected]
444 462 end
445 463
446 464 socket
447 465 |> assign(
448 - active_option: -1,
466 + active_option: if(quick_tags_mode?(socket), do: socket.assigns.active_option, else: -1),
449 467 selection: selection,
450 - hide_dropdown: true
468 + hide_dropdown: not quick_tags_mode?(socket)
451 469 )
452 470 |> maybe_save_selection()
453 471 |> client_select(Map.merge(%{input_event: true}, extra_params))
  @@ -521,7 +539,7 @@ defmodule LiveSelect.Component do
521 539 List.wrap(normalize_selection_value(value, options ++ current_selection, value_mapper))
522 540 end
523 541
524 - defp update_selection(value, current_selection, options, :tags, value_mapper) do
542 + defp update_selection(value, current_selection, options, _mode, value_mapper) do
525 543 value = if Enumerable.impl_for(value), do: value, else: [value]
526 544
527 545 Enum.map(value, &normalize_selection_value(&1, options ++ current_selection, value_mapper))
  @@ -665,7 +683,11 @@ defmodule LiveSelect.Component do
665 683 defp encode(value), do: Jason.encode!(value)
666 684
667 685 defp already_selected?(option, selection) do
668 - option.label in Enum.map(selection, & &1.label)
686 + Enum.any?(selection, fn item -> item.label == option.label end)
687 + end
688 +
689 + defp quick_tags_mode?(socket) do
690 + socket.assigns.mode == :quick_tags
669 691 end
670 692
671 693 defp next_selectable(%{
  @@ -676,10 +698,17 @@ defmodule LiveSelect.Component do
676 698 when max_selectable > 0 and length(selection) >= max_selectable,
677 699 do: active_option
678 700
679 - defp next_selectable(%{options: options, active_option: active_option, selection: selection}) do
701 + defp next_selectable(%{
702 + options: options,
703 + active_option: active_option,
704 + selection: selection,
705 + mode: mode
706 + }) do
680 707 options
681 708 |> Enum.with_index()
682 - |> Enum.reject(fn {opt, _} -> active_option == opt || already_selected?(opt, selection) end)
709 + |> Enum.reject(fn {opt, _} ->
710 + active_option == opt || (mode != :quick_tags && already_selected?(opt, selection))
711 + end)
683 712 |> Enum.map(fn {_, idx} -> idx end)
684 713 |> Enum.find(active_option, &(&1 > active_option))
685 714 end
  @@ -692,11 +721,18 @@ defmodule LiveSelect.Component do
692 721 when max_selectable > 0 and length(selection) >= max_selectable,
693 722 do: active_option
694 723
695 - defp prev_selectable(%{options: options, active_option: active_option, selection: selection}) do
724 + defp prev_selectable(%{
725 + options: options,
726 + active_option: active_option,
727 + selection: selection,
728 + mode: mode
729 + }) do
696 730 options
697 731 |> Enum.with_index()
698 732 |> Enum.reverse()
699 - |> Enum.reject(fn {opt, _} -> active_option == opt || already_selected?(opt, selection) end)
733 + |> Enum.reject(fn {opt, _} ->
734 + active_option == opt || (mode != :quick_tags && already_selected?(opt, selection))
735 + end)
700 736 |> Enum.map(fn {_, idx} -> idx end)
701 737 |> Enum.find(active_option, &(&1 < active_option || active_option == -1))
702 738 end
  @@ -8,10 +8,8 @@
8 8 data-field={@field.id}
9 9 data-debounce={@debounce}
10 10 >
11 - <%= if @mode == :tags && Enum.any?(@selection) do %>
12 - <div class={
13 - class(@style, :tags_container, @tags_container_class, @tags_container_extra_class)
14 - }>
11 + <div class={class(@style, :tags_container, @tags_container_class, @tags_container_extra_class)}>
12 + <%= if (@mode in [:tags, :quick_tags]) && Enum.any?(@selection) do %>
15 13 <%= for {option, idx} <- Enum.with_index(@selection) do %>
16 14 <div class={class(@style, :tag, @tag_class, @tag_extra_class)}>
17 15 <%= if @tag == [] do %>
  @@ -40,8 +38,9 @@
40 38 </button>
41 39 </div>
42 40 <% end %>
43 - </div>
44 - <% end %>
41 + <% end %>
42 + </div>
43 +
45 44 <div>
46 45 <%= text_input(@field.form, @text_input_field,
47 46 class:
  @@ -122,12 +121,17 @@
122 121 )
123 122 )
124 123 }
125 - data-idx={unless already_selected?(option, @selection), do: idx}
124 + data-idx={
125 + if @mode == :quick_tags or not already_selected?(option, @selection), do: idx
126 + }
126 127 >
127 128 <%= if @option == [] do %>
128 129 <%= option.label %>
129 130 <% else %>
130 - <%= render_slot(@option, option) %>
131 + <%= render_slot(
132 + @option,
133 + Map.merge(option, %{selected: already_selected?(option, @selection)})
134 + ) %>
131 135 <% end %>
132 136 </div>
133 137 </li>
  @@ -4,7 +4,7 @@ defmodule LiveSelect.MixProject do
4 4 def project do
5 5 [
6 6 app: app(),
7 - version: "1.4.4",
7 + version: "1.5.0",
8 8 elixir: "~> 1.14",
9 9 description: "Dynamic (multi)selection field for LiveView",
10 10 elixirc_paths: elixirc_paths(Mix.env()),
Loading more files…