diff --git a/assets/js/live_select.js b/assets/js/live_select.js index d105f35..f5300f5 100644 --- a/assets/js/live_select.js +++ b/assets/js/live_select.js @@ -49,7 +49,11 @@ export default { if (event.code === "Enter") { event.preventDefault() } - this.pushEventTo(this.el, 'keydown', { key: event.code }) + const options = Array.from(this.el.querySelectorAll('div[data-idx]')) + const visualOrder = options + .sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top) + .map(el => parseInt(el.dataset.idx)) + this.pushEventTo(this.el, 'keydown', { key: event.code, visual_order: visualOrder }) } this.changeEvents = debounce((id, field, text) => { this.pushEventTo(this.el, "change", { text }) diff --git a/lib/live_select/component.ex b/lib/live_select/component.ex index c08e452..d28a0be 100644 --- a/lib/live_select/component.ex +++ b/lib/live_select/component.ex @@ -271,8 +271,9 @@ defmodule LiveSelect.Component do def handle_event("options_clear", _params, socket), do: {:noreply, clear_options(socket)} @impl true - def handle_event("keydown", %{"key" => "ArrowDown"}, socket) do - active_option = next_selectable(socket.assigns) + def handle_event("keydown", %{"key" => "ArrowDown"} = params, socket) do + visual_order = Map.get(params, "visual_order", default_visual_order(socket.assigns)) + active_option = next_selectable(socket.assigns, visual_order) socket = assign(socket, @@ -285,8 +286,9 @@ defmodule LiveSelect.Component do end @impl true - def handle_event("keydown", %{"key" => "ArrowUp"}, socket) do - active_option = prev_selectable(socket.assigns) + def handle_event("keydown", %{"key" => "ArrowUp"} = params, socket) do + visual_order = Map.get(params, "visual_order", default_visual_order(socket.assigns)) + active_option = prev_selectable(socket.assigns, visual_order) socket = assign(socket, @@ -755,55 +757,77 @@ defmodule LiveSelect.Component do end end - defp next_selectable(%{ - selection: selection, - active_option: active_option, - max_selectable: max_selectable, - mode: mode - }) + defp default_visual_order(%{options: options}) do + Enum.to_list(0..(length(options) - 1)) + end + + defp selectable_in_visual_order( + %{options: options, selection: selection, mode: mode}, + visual_order + ) do + Enum.filter(visual_order, fn idx -> + option = Enum.at(options, idx) + + option && not Map.get(option, :disabled, false) && + (mode == :quick_tags || not already_selected?(option, selection)) + end) + end + + defp next_selectable( + %{ + selection: selection, + active_option: active_option, + max_selectable: max_selectable, + mode: mode + }, + _visual_order + ) when mode != :quick_tags and max_selectable > 0 and length(selection) >= max_selectable, do: active_option - defp next_selectable(%{ - options: options, - active_option: active_option, - selection: selection, - mode: mode - }) do - options - |> Enum.with_index() - |> Enum.reject(fn {opt, _} -> - active_option == opt || (mode != :quick_tags && already_selected?(opt, selection)) || - Map.get(opt, :disabled) - end) - |> Enum.map(fn {_, idx} -> idx end) - |> Enum.find(active_option, &(&1 > active_option)) + defp next_selectable(%{active_option: active_option} = assigns, visual_order) do + selectable = selectable_in_visual_order(assigns, visual_order) + + case active_option do + -1 -> + List.first(selectable, -1) + + current -> + case Enum.drop_while(selectable, &(&1 != current)) do + [_ | [next | _]] -> next + _ -> current + end + end end - defp prev_selectable(%{ - selection: selection, - active_option: active_option, - max_selectable: max_selectable, - mode: mode - }) + defp prev_selectable( + %{ + selection: selection, + active_option: active_option, + max_selectable: max_selectable, + mode: mode + }, + _visual_order + ) when mode != :quick_tags and max_selectable > 0 and length(selection) >= max_selectable, do: active_option - defp prev_selectable(%{ - options: options, - active_option: active_option, - selection: selection, - mode: mode - }) do - options - |> Enum.with_index() - |> Enum.reverse() - |> Enum.reject(fn {opt, _} -> - active_option == opt || (mode != :quick_tags && already_selected?(opt, selection)) || - Map.get(opt, :disabled) - end) - |> Enum.map(fn {_, idx} -> idx end) - |> Enum.find(active_option, &(&1 < active_option || active_option == -1)) + defp prev_selectable(%{active_option: active_option} = assigns, visual_order) do + selectable = selectable_in_visual_order(assigns, visual_order) + + case active_option do + -1 -> + List.last(selectable, -1) + + current -> + selectable + |> Enum.reverse() + |> Enum.drop_while(&(&1 != current)) + |> case do + [_ | [prev | _]] -> prev + _ -> current + end + end end defp x(assigns) do diff --git a/priv/static/live_select.min.js b/priv/static/live_select.min.js index a799072..2e46ecd 100644 --- a/priv/static/live_select.min.js +++ b/priv/static/live_select.min.js @@ -1 +1 @@ -function o(e,t){let i;return(...s)=>{clearTimeout(i),i=setTimeout(()=>{e.apply(this,s)},t)}}export default{LiveSelect:{textInput(){return this.el.querySelector("input[type=text]")},debounceMsec(){return parseInt(this.el.dataset.debounce)},updateMinLen(){return parseInt(this.el.dataset.updateMinLen)},maybeStyleClearButtons(){const e=this.el.querySelector("button.ls-clear-button");e&&(this.textInput().parentElement.style.position="relative",this.textInput().parentElement.style.display="flex",this.textInput().parentElement.style.alignItems="center",e.style.minHeight="20px",e.style.minWidth="20px",e.style.position="absolute",e.style.right="5px",e.style.display="block"),this.el.querySelectorAll("button.ls-clear-tag-button").forEach(t=>{t.style.minHeight="20px",t.style.minWidth="20px"})},pushEventToParent(e,t){const i=this.el.dataset.phxTarget;i?this.pushEventTo(i,e,t):this.pushEvent(e,t)},attachDomEventHandlers(){this.textInput().onkeydown=t=>{t.code==="Enter"&&t.preventDefault(),this.pushEventTo(this.el,"keydown",{key:t.code})},this.changeEvents=o((t,i,s)=>{this.pushEventTo(this.el,"change",{text:s}),this.pushEventToParent("live_select_change",{id:this.el.id,field:i,text:s})},this.debounceMsec()),this.textInput().oninput=t=>{const i=t.target.value.trim(),s=this.el.dataset.field;i.length>=this.updateMinLen()?this.changeEvents(this.el.id,s,i):this.pushEventTo(this.el,"options_clear",{})};const e=this.el.querySelector("ul");e&&(e.onmousedown=t=>{const i=t.target.closest("div[data-idx]");i&&(this.pushEventTo(this.el,"option_click",{idx:i.dataset.idx}),t.preventDefault())}),this.el.querySelectorAll("button[data-idx]").forEach(t=>{t.onclick=i=>{this.pushEventTo(this.el,"option_remove",{idx:t.dataset.idx})}})},setInputValue(e){this.textInput().value=e},inputEvent(e,t){const i=t==="single"?"input.single-mode":e.length===0?"input[data-live-select-empty]":"input[type=hidden]";this.el.querySelector(i).dispatchEvent(new Event("input",{bubbles:!0}))},mounted(){this.maybeStyleClearButtons(),this.handleEvent("select",({id:e,selection:t,mode:i,current_text:s,input_event:l,parent_event:n})=>{this.el.id===e&&(this.selection=t,s!=null&&this.setInputValue(s),l&&this.inputEvent(t,i),n&&this.pushEventToParent(n,{id:e}))}),this.handleEvent("scroll_to_option",({id:e,idx:t})=>{if(this.el.id===e){const i=this.el.querySelector(`div[data-idx="${t}"]`);i&&i.scrollIntoView({block:"nearest",behavior:"instant",container:"nearest"})}}),this.attachDomEventHandlers()},updated(){this.maybeStyleClearButtons(),this.attachDomEventHandlers()},reconnected(){this.selection&&this.selection.length>0&&this.pushEventTo(this.el,"selection_recovery",this.selection)}}}; +function o(e,t){let i;return(...s)=>{clearTimeout(i),i=setTimeout(()=>{e.apply(this,s)},t)}}export default{LiveSelect:{textInput(){return this.el.querySelector("input[type=text]")},debounceMsec(){return parseInt(this.el.dataset.debounce)},updateMinLen(){return parseInt(this.el.dataset.updateMinLen)},maybeStyleClearButtons(){const e=this.el.querySelector("button.ls-clear-button");e&&(this.textInput().parentElement.style.position="relative",this.textInput().parentElement.style.display="flex",this.textInput().parentElement.style.alignItems="center",e.style.minHeight="20px",e.style.minWidth="20px",e.style.position="absolute",e.style.right="5px",e.style.display="block"),this.el.querySelectorAll("button.ls-clear-tag-button").forEach(t=>{t.style.minHeight="20px",t.style.minWidth="20px"})},pushEventToParent(e,t){const i=this.el.dataset.phxTarget;i?this.pushEventTo(i,e,t):this.pushEvent(e,t)},attachDomEventHandlers(){this.textInput().onkeydown=t=>{t.code==="Enter"&&t.preventDefault();const s=Array.from(this.el.querySelectorAll("div[data-idx]")).sort((n,l)=>n.getBoundingClientRect().top-l.getBoundingClientRect().top).map(n=>parseInt(n.dataset.idx));this.pushEventTo(this.el,"keydown",{key:t.code,visual_order:s})},this.changeEvents=o((t,i,s)=>{this.pushEventTo(this.el,"change",{text:s}),this.pushEventToParent("live_select_change",{id:this.el.id,field:i,text:s})},this.debounceMsec()),this.textInput().oninput=t=>{const i=t.target.value.trim(),s=this.el.dataset.field;i.length>=this.updateMinLen()?this.changeEvents(this.el.id,s,i):this.pushEventTo(this.el,"options_clear",{})};const e=this.el.querySelector("ul");e&&(e.onmousedown=t=>{const i=t.target.closest("div[data-idx]");i&&(this.pushEventTo(this.el,"option_click",{idx:i.dataset.idx}),t.preventDefault())}),this.el.querySelectorAll("button[data-idx]").forEach(t=>{t.onclick=i=>{this.pushEventTo(this.el,"option_remove",{idx:t.dataset.idx})}})},setInputValue(e){this.textInput().value=e},inputEvent(e,t){const i=t==="single"?"input.single-mode":e.length===0?"input[data-live-select-empty]":"input[type=hidden]";this.el.querySelector(i).dispatchEvent(new Event("input",{bubbles:!0}))},mounted(){this.maybeStyleClearButtons(),this.handleEvent("select",({id:e,selection:t,mode:i,current_text:s,input_event:n,parent_event:l})=>{this.el.id===e&&(this.selection=t,s!=null&&this.setInputValue(s),n&&this.inputEvent(t,i),l&&this.pushEventToParent(l,{id:e}))}),this.handleEvent("scroll_to_option",({id:e,idx:t})=>{if(this.el.id===e){const i=this.el.querySelector(`div[data-idx="${t}"]`);i&&i.scrollIntoView({block:"nearest",behavior:"instant",container:"nearest"})}}),this.attachDomEventHandlers()},updated(){this.maybeStyleClearButtons(),this.attachDomEventHandlers()},reconnected(){this.selection&&this.selection.length>0&&this.pushEventTo(this.el,"selection_recovery",this.selection)}}};