├── .credo.exs ├── .dockerignore ├── .formatter.exs ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── elixir.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── css │ └── app.css ├── js │ ├── app.js │ └── live_select.js ├── package.json ├── tailwind.config.js ├── vendor │ └── topbar.js └── yarn.lock ├── cheatsheet.cheatmd ├── config ├── config.exs ├── demo.exs ├── dev.exs ├── runtime.exs └── test.exs ├── fly.toml ├── lib ├── live_select.ex ├── live_select │ ├── class_util.ex │ ├── component.ex │ └── component.html.heex ├── mix │ └── tasks │ │ └── dump_style_table.ex └── support │ ├── application.ex │ ├── change_event_handler.ex │ ├── city.ex │ ├── city_finder.ex │ ├── live_select_web.ex │ └── live_select_web │ ├── components │ ├── layouts.ex │ └── layouts │ │ ├── app.html.heex │ │ └── root.html.heex │ ├── controllers │ ├── error_html.ex │ └── error_json.ex │ ├── endpoint.ex │ ├── live │ ├── live_component_form.ex │ ├── live_component_test.ex │ ├── showcase_live.ex │ └── showcase_live.html.heex │ ├── router.ex │ └── views │ └── error_helpers.ex ├── mix.exs ├── mix.lock ├── package.json ├── priv ├── cities.json └── static │ ├── favicon.ico │ ├── images │ ├── daisyui.png │ ├── demo_quick_tags.gif │ ├── demo_single.gif │ ├── demo_tags.gif │ ├── github-mark-white.png │ ├── github-mark.png │ ├── showcase.gif │ ├── slots.png │ ├── styled_elements.png │ ├── styled_elements_tags.png │ └── tailwind.png │ ├── live_select.min.js │ └── robots.txt ├── rel └── overlays │ └── bin │ ├── server │ └── server.bat ├── styling.md └── test ├── live_select ├── class_util_test.exs └── component_test.exs ├── live_select_quick_tags_test.exs ├── live_select_tags_test.exs ├── live_select_test.exs ├── support ├── conn_case.ex └── helpers.ex └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: [ 25 | "lib/", 26 | "src/", 27 | "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 35 | }, 36 | # 37 | # Load and configure plugins here: 38 | # 39 | plugins: [], 40 | # 41 | # If you create your own checks, you must specify the source files for 42 | # them here, so they can be loaded by Credo before running the analysis. 43 | # 44 | requires: [], 45 | # 46 | # If you want to enforce a style guide and need a more traditional linting 47 | # experience, you can change `strict` to `true` below: 48 | # 49 | strict: false, 50 | # 51 | # To modify the timeout for parsing files, change this value: 52 | # 53 | parse_timeout: 5000, 54 | # 55 | # If you want to use uncolored output by default, you can change `color` 56 | # to `false` below: 57 | # 58 | color: true, 59 | # 60 | # You can customize the parameters of any check by adding a second element 61 | # to the tuple. 62 | # 63 | # To disable a check put `false` as second element: 64 | # 65 | # {Credo.Check.Design.DuplicatedCode, false} 66 | # 67 | checks: %{ 68 | enabled: [ 69 | # 70 | ## Consistency Checks 71 | # 72 | {Credo.Check.Consistency.ExceptionNames, []}, 73 | {Credo.Check.Consistency.LineEndings, []}, 74 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 75 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 76 | {Credo.Check.Consistency.SpaceInParentheses, []}, 77 | {Credo.Check.Consistency.TabsOrSpaces, []}, 78 | 79 | # 80 | ## Design Checks 81 | # 82 | # You can customize the priority of any check 83 | # Priority values are: `low, normal, high, higher` 84 | # 85 | {Credo.Check.Design.AliasUsage, 86 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 87 | # You can also customize the exit_status of each check. 88 | # If you don't want TODO comments to cause `mix credo` to fail, just 89 | # set this value to 0 (zero). 90 | # 91 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 92 | {Credo.Check.Design.TagFIXME, []}, 93 | 94 | # 95 | ## Readability Checks 96 | # 97 | {Credo.Check.Readability.AliasOrder, []}, 98 | {Credo.Check.Readability.FunctionNames, []}, 99 | {Credo.Check.Readability.LargeNumbers, []}, 100 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 101 | {Credo.Check.Readability.ModuleAttributeNames, []}, 102 | {Credo.Check.Readability.ModuleDoc, []}, 103 | {Credo.Check.Readability.ModuleNames, []}, 104 | {Credo.Check.Readability.ParenthesesInCondition, []}, 105 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 106 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, 107 | {Credo.Check.Readability.PredicateFunctionNames, []}, 108 | {Credo.Check.Readability.PreferImplicitTry, []}, 109 | {Credo.Check.Readability.RedundantBlankLines, []}, 110 | {Credo.Check.Readability.Semicolons, []}, 111 | {Credo.Check.Readability.SpaceAfterCommas, []}, 112 | {Credo.Check.Readability.StringSigils, []}, 113 | {Credo.Check.Readability.TrailingBlankLine, []}, 114 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 115 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 116 | {Credo.Check.Readability.VariableNames, []}, 117 | {Credo.Check.Readability.WithSingleClause, []}, 118 | 119 | # 120 | ## Refactoring Opportunities 121 | # 122 | {Credo.Check.Refactor.Apply, []}, 123 | {Credo.Check.Refactor.CondStatements, []}, 124 | {Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 12]}, 125 | {Credo.Check.Refactor.FunctionArity, []}, 126 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 127 | {Credo.Check.Refactor.MatchInCondition, []}, 128 | {Credo.Check.Refactor.MapJoin, []}, 129 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 130 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 131 | {Credo.Check.Refactor.Nesting, [max_nesting: 3]}, 132 | {Credo.Check.Refactor.UnlessWithElse, []}, 133 | {Credo.Check.Refactor.WithClauses, []}, 134 | {Credo.Check.Refactor.FilterFilter, []}, 135 | {Credo.Check.Refactor.RejectReject, []}, 136 | {Credo.Check.Refactor.RedundantWithClauseResult, []}, 137 | {Credo.Check.Refactor.IoPuts, []}, 138 | 139 | # 140 | ## Warnings 141 | # 142 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 143 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 144 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 145 | {Credo.Check.Warning.IExPry, []}, 146 | {Credo.Check.Warning.IoInspect, []}, 147 | {Credo.Check.Warning.OperationOnSameValues, []}, 148 | {Credo.Check.Warning.OperationWithConstantResult, []}, 149 | {Credo.Check.Warning.RaiseInsideRescue, []}, 150 | {Credo.Check.Warning.SpecWithStruct, []}, 151 | {Credo.Check.Warning.WrongTestFileExtension, []}, 152 | {Credo.Check.Warning.UnusedEnumOperation, []}, 153 | {Credo.Check.Warning.UnusedFileOperation, []}, 154 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 155 | {Credo.Check.Warning.UnusedListOperation, []}, 156 | {Credo.Check.Warning.UnusedPathOperation, []}, 157 | {Credo.Check.Warning.UnusedRegexOperation, []}, 158 | {Credo.Check.Warning.UnusedStringOperation, []}, 159 | {Credo.Check.Warning.UnusedTupleOperation, []}, 160 | {Credo.Check.Warning.UnsafeExec, []} 161 | ], 162 | disabled: [ 163 | # 164 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 165 | 166 | # 167 | # Controversial and experimental checks (opt-in, just move the check to `:enabled` 168 | # and be sure to use `mix credo --strict` to see low priority checks) 169 | # 170 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 171 | {Credo.Check.Consistency.UnusedVariableNames, []}, 172 | {Credo.Check.Design.DuplicatedCode, []}, 173 | {Credo.Check.Design.SkipTestWithoutComment, []}, 174 | {Credo.Check.Readability.AliasAs, []}, 175 | {Credo.Check.Readability.BlockPipe, []}, 176 | {Credo.Check.Readability.ImplTrue, []}, 177 | {Credo.Check.Readability.MultiAlias, []}, 178 | {Credo.Check.Readability.NestedFunctionCalls, []}, 179 | {Credo.Check.Readability.SeparateAliasRequire, []}, 180 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, 181 | {Credo.Check.Readability.SinglePipe, []}, 182 | {Credo.Check.Readability.Specs, []}, 183 | {Credo.Check.Readability.StrictModuleLayout, []}, 184 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 185 | {Credo.Check.Refactor.ABCSize, []}, 186 | {Credo.Check.Refactor.AppendSingleItem, []}, 187 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 188 | {Credo.Check.Refactor.FilterReject, []}, 189 | {Credo.Check.Refactor.MapMap, []}, 190 | {Credo.Check.Refactor.ModuleDependencies, []}, 191 | {Credo.Check.Refactor.NegatedIsNil, []}, 192 | {Credo.Check.Refactor.PipeChainStart, []}, 193 | {Credo.Check.Refactor.RejectFilter, []}, 194 | {Credo.Check.Refactor.VariableRebinding, []}, 195 | {Credo.Check.Warning.LazyLogging, []}, 196 | {Credo.Check.Warning.LeakyEnvironment, []}, 197 | {Credo.Check.Warning.MapGetUnsafePass, []}, 198 | {Credo.Check.Warning.MixEnv, []}, 199 | {Credo.Check.Warning.UnsafeToAtom, []} 200 | 201 | # {Credo.Check.Refactor.MapInto, []}, 202 | 203 | # 204 | # Custom checks can be created using `mix credo.gen.check`. 205 | # 206 | ] 207 | } 208 | } 209 | ] 210 | } 211 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # flyctl launch added from .gitignore 2 | # The directory Mix will write compiled artifacts to. 3 | _build 4 | 5 | # If you run "mix test --cover", coverage assets end up here. 6 | cover 7 | 8 | # The directory Mix downloads your dependencies sources to. 9 | deps 10 | 11 | # Where 3rd-party dependencies like ExDoc output generated docs. 12 | doc 13 | 14 | # Ignore .fetch files in case you like to edit your project deps locally. 15 | .fetch 16 | 17 | # If the VM crashes, it generates a dump, let's ignore it too. 18 | **/erl_crash.dump 19 | 20 | # Also ignore archive artifacts (built via "mix archive.build"). 21 | **/*.ez 22 | 23 | # Ignore package tarball (built via "mix hex.build"). 24 | **/live_select-*.tar 25 | 26 | /priv/static/assets/ 27 | /priv/static/cache_manifest.json 28 | 29 | # In case you use Node.js/npm, you want to ignore these. 30 | **/npm-debug.log 31 | assets/node_modules 32 | 33 | **/.idea 34 | **/*.iml 35 | 36 | # flyctl launch added from .idea/.gitignore 37 | # Default ignored files 38 | .idea/shelf 39 | .idea/workspace.xml 40 | # Editor-based HTTP Client requests 41 | .idea/httpRequests 42 | # Datasource local storage ignored files 43 | .idea/dataSources 44 | .idea/dataSources.local.xml 45 | fly.toml 46 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | plugins: [ 3 | Phoenix.LiveView.HTMLFormatter 4 | ], 5 | import_deps: [:phoenix], 6 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"], 7 | migrate_eex_to_curly_interpolation: false 8 | ] 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **LiveSelect and LiveView versions** 11 | have you tried LiveSelect's and LiveView's latest versions? Please try them and see if the bug still occur 12 | which LiveSelect version are you on? (`mix deps | grep live_select`) 13 | which LiveView version are you on? (`mix deps | grep phoenix_live_view`) 14 | 15 | **Describe the bug** 16 | A clear and concise description of what the bug is. 17 | 18 | **Expected behavior** 19 | What were you expecting to happen? 20 | 21 | **Actual behavior** 22 | What is actually going on instead? 23 | 24 | **Screenshots** 25 | If applicable (in most cases it is), do add a screenshot (or even better, a GIF or a video) that describes the problem. 26 | 27 | **Browsers** 28 | On which browsers did you notice the issue? 29 | 30 | **Issue Repo** 31 | You have the best chances of someone fixing the issue if you include a minimal repo that reproduces the problem. 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-24.04 15 | strategy: 16 | matrix: 17 | include: 18 | # LiveView 1.0 isn't compatible with 1.14.0 19 | - elixir: '1.14.1' 20 | otp: '25' 21 | - elixir: '1.15' 22 | otp: '26' 23 | - elixir: '1.16' 24 | otp: '26' 25 | - elixir: '1.17' 26 | otp: '27' 27 | lint: true 28 | - elixir: '1.18' 29 | otp: '27' 30 | lint: true 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | 35 | - name: Set up Elixir 36 | uses: erlef/setup-beam@v1 37 | with: 38 | elixir-version: ${{ matrix.elixir }} 39 | otp-version: ${{ matrix.otp }} 40 | 41 | - name: Restore dependencies cache 42 | uses: actions/cache@v3 43 | with: 44 | path: deps 45 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 46 | restore-keys: ${{ runner.os }}-mix- 47 | 48 | - name: Install dependencies 49 | run: mix deps.get 50 | 51 | - name: Compile with warnings 52 | run: mix compile --warnings-as-errors 53 | if: ${{ matrix.lint }} 54 | 55 | - name: Compile without warnings 56 | run: mix compile 57 | 58 | - name: Check format 59 | run: mix format --check-formatted 60 | if: ${{ matrix.lint }} 61 | 62 | - name: Credo 63 | run: mix credo 64 | if: ${{ matrix.lint }} 65 | 66 | - name: Run tests 67 | run: mix test 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | live_select-*.tar 24 | 25 | # Ignore assets that are produced by build tools. 26 | 27 | /priv/static/assets/ 28 | 29 | # Ignore digested assets cache. 30 | /priv/static/cache_manifest.json 31 | 32 | # In case you use Node.js/npm, you want to ignore these. 33 | npm-debug.log 34 | /assets/node_modules/ 35 | 36 | .idea/ 37 | *.iml -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.17.3-otp-27 2 | erlang 27.1.3 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ### Added 4 | * DaisyUI 5 compatibility - active options now include both `active` and `menu-active` classes for compatibility with DaisyUI 3, 4, and 5. 5 | 6 | ## 1.6.0 (2025-04-13) 7 | 8 | * add ability to disable options 9 | 10 | ## 1.5.5 (2025-03-31) 11 | 12 | * make clear buttons also honor the disabled attribute 13 | * remember entered text when blurring away from the element 14 | 15 | ## 1.5.4 (2025-01-29) 16 | 17 | * Fix [bug](https://github.com/maxmarcon/live_select/issues/98) causing selection recovery to fail if Phoenix uses the built-in JSON module from Elixir 1.18 18 | 19 | ## 1.5.3 (2025-01-27) 20 | 21 | * Fix [bug](https://github.com/maxmarcon/live_select/issues/96) that causes selection recovery to fail when `input_for` is used to render the component 22 | * Remove dependency from `Jason` library 23 | 24 | ## 1.5.2 (2024-12-28) 25 | 26 | * Fix [bug](https://github.com/maxmarcon/live_select/issues/70) where the keyboard doesn't show on some mobile browsers if an element is already selected in single mode 27 | 28 | ## 1.5.1 (2024-12-28) 29 | 30 | * Add unavailable_option_class to style options that cannot be selected because of max_selectable > 0 31 | * Fix can't remove items in quick_tags mode via dropdown with max_selectable 32 | 33 | ## 1.5.0 (2024-12-27) 34 | 35 | * new quick_tags mode 36 | * showcase app: add toggle to style options as checkboxes 37 | 38 | ## 1.4.4 (2024-12-07) 39 | 40 | * support for LiveView 1.0.0 41 | 42 | ## 1.4.3 (2024-10-28) 43 | 44 | * add options clear_tag_button_class and clear_tag_button_extra_class to style button to clear tags 45 | * fix [bug](https://github.com/maxmarcon/live_select/issues/81) where selection was not restored after focus+blur when value is pre-selected or forced 46 | 47 | ## 1.4.2 (2024-06-19) 48 | 49 | do not restore selection after blur it it was cleard by hitting the clear button 50 | 51 | ## 1.4.1 (2024-06-18) 52 | 53 | * fix bug introduced by LV-1.0's new focus behavior (https://github.com/maxmarcon/live_select/issues/72) 54 | 55 | ## 1.4.0 (2024-03-18) 56 | 57 | * support for associations and embeds 58 | * add `value_mapper` assign and `decode/1` function 59 | 60 | ## 1.3.3 (2024-02-06) 61 | 62 | * add slot to render custom clear button 63 | * add option to extend clear button style 64 | 65 | ## 1.3.2 (2024-01-26) 66 | 67 | * updated dependencies, including updating `phoenix_html` to `4.0.0` 68 | 69 | ## 1.3.1 (2023-12-06) 70 | 71 | * bugfix: only set selection in client if event was sent to this component 72 | 73 | ## 1.3.0 (2023-12-05) 74 | 75 | * added support for selection recovery. Upon reconnection, the client sends an event (`selection_recovery`) that contains the latest selection. This allows recovery of the selection that was active before the view disconnected. 76 | 77 | ## 1.2.2 (2023-10-21) 78 | 79 | * daisyui3-compatible 80 | * arrowUp when there is no active option navigates to the last option 81 | * scroll options into view when they become active 82 | 83 | ## 1.2.1 (2023-10-17) 84 | 85 | * fix bug that was causing dropdown to overflow container (https://github.com/maxmarcon/live_select/issues/43) 86 | 87 | ## 1.2.0 (2023-09-25) 88 | 89 | * add `clear_button_class` option to style clear buttons for tags and selection 90 | * various bugfixes and improvements 91 | 92 | ## 1.1.1 (2023-07-21) 93 | 94 | * accept `sticky` flag in an option to prevent it from being removed (https://github.com/maxmarcon/live_select/pull/33) 95 | * when selection becomes empty, an update is triggered with a hidden field named after `live_select`'s field's name 96 | 97 | (thanks to https://github.com/shamanime for both changes) 98 | 99 | ## 1.1.0 (2023-06-26) 100 | 101 | * add `phx-focus` and `phx-blur` options to specify events to be sent to the parent upon focus and blur of the text input field 102 | * send live_select_change event directly from JS hook to save a round-trip 103 | * expects a single `field` assign of type `Phoenix.HTML.FormField` instead of separate form and field assigns (which is still supported but soft-deprecated with a warning) 104 | 105 | ## 1.0.4 (2023-05-30) 106 | 107 | * Do not use name attribute on non-input elements to prevent LV from crashing 108 | * Change default for update_min_len to 1 109 | 110 | ## 1.0.3 (2023-03-31) 111 | 112 | * Programmatically override selection with value assign 113 | * Only clear options if entered text is shorter than update_min_len and user types backspace 114 | 115 | Bugfix: fix selection via mouseclick not working when rendering nested elements in the :option slot 116 | 117 | ## 1.0.2 (2023-03-20) 118 | 119 | styling options now also accept lists of strings 120 | 121 | ## 1.0.1 (2023-02-18) 122 | 123 | Bugfix: fix error when using atom form 124 | 125 | ## 1.0.0 (2023-02-15) 126 | 127 | This version introduces the following breaking changes and new features: 128 | 129 | * Rendering using a function component `<.live_select />` instead of the old function style (`<%= live_select ... %>`) 130 | * Dropping the message-based update cycle (which used `handle_info/2`) in favour of an event-based update cycle (which uses `handle_event/3`). This makes it much easier 131 | and more intuitive to use LiveSelect from another LiveComponent. 132 | * Ability to customize the default rendering of dropdown entries and tags using the `:option` and `:tag` slots 133 | 134 | ** How to upgrade from version 0.x.x: ** 135 | 136 | 1. Instead of rendering LiveSelect in this way: `<%= live_select form, field, mode: :tags %>`, render it in this way: `<.live_select form={form} field={field} mode={:tags} />` 137 | 2. Don't forget to add `phx-target={@myself}` if you're using LiveSelect from another LiveComponent 138 | 3. Turn your `handle_info/2` implementations into `handle_event/3`: 139 | 140 | Turn this: 141 | 142 | ```elixir 143 | def handle_info(%ChangeMsg{} = change_msg, socket) do 144 | options = retrieve_options(change_msg.text) 145 | 146 | send_update(LiveSelect.Component, id: change_msg.id, options: options) 147 | 148 | {:noreply, socket} 149 | end 150 | ``` 151 | 152 | into: 153 | 154 | ```elixir 155 | def handle_event("live_select_change", %{"text" => text, "id" => live_select_id}, socket) do 156 | options = retrieve_options(text) 157 | 158 | send_update(LiveSelect.Component, id: live_select_id, options: options) 159 | 160 | {:noreply, socket} 161 | end 162 | ``` 163 | 164 | 4. If you're rendering LiveSelect in a LiveComponent, you can now place your `handle_event/3` callback in the LiveComponent, there's no need to put the update logic in the view anymore 165 | 166 | ## 0.4.1 (2023-02-07) 167 | 168 | Bugfix: component now works event when strict Content Security Policy are set 169 | 170 | ## 0.4.0 (2023-01-30) 171 | 172 | * add `available_option_class` configuration option to style options that have not been selected yet 173 | * add `user_defined_options` configuration option to allow user to enter any tag 174 | * enable assigning a custom id to the component 175 | * enable programmatically clearing of the selection via `clear` assign 176 | * add `allow_clear` configuration option. If set, an `x` button will appear next to the text input in single mode. Clicking the button clears the selection 177 | 178 | ### Deprecations 179 | 180 | LiveSelect.update_options/2 has been deprecated in favor of directly using Phoenix.LiveView.send_update/3 181 | 182 | ## 0.3.3 (2023-01-15) 183 | 184 | * set initial selection from the form or manually with `value` option 185 | * set initial list of available options with `options` option 186 | * add a `max_selectable` option to limit the maximum size of the selection 187 | 188 | ## 0.3.2 (2023-01-03) 189 | 190 | Bugfix: options in dropdown not always clickable because of race condition with blur event (https://github.com/maxmarcon/live_select/issues/7) 191 | 192 | ## 0.3.1 (2022-12-15) 193 | 194 | Bugfix: removed inputs_for because it was failing if the field is not an association 195 | 196 | ## 0.3.0 (2022-12-15) 197 | 198 | * tags mode 199 | * hide dropdown on escape key pressed 200 | 201 | ## 0.2.1 (2022-10-25) 202 | 203 | * when disabled option is used, also disable hidden input 204 | * style disabled text input in tailwind mode 205 | * fix problem with selection via mouseclick when an input field is underneath the dropdown 206 | * hide dropdown when user clicks away from component or input loses focus 207 | * show dropdown when input obtains focus again 208 | * using default black text in tailwind mode 209 | 210 | ## 0.2.0 (2022-10-03) 211 | 212 | * support for tailwind styles (now the default) 213 | * more opinionated default styles 214 | * ability to selectively remove classes from style defaults using the !class_name notation 215 | * rename option search_term_min_length to update_min_len 216 | * better error messages 217 | * various improvements to the showcase app 218 | 219 | ## 0.1.4 (2022-09-20) 220 | 221 | * raise if class and extra_class options are used in invalid combinations (https://github.com/maxmarcon/live_select/issues/2) 222 | 223 | ### Bugfixes 224 | 225 | * route server events to the right live select component using the component `id` (https://github.com/maxmarcon/live_select/issues/1) 226 | 227 | ## 0.1.3 (2022-08-12) 228 | 229 | * rename LiveSelect.update/2 to LiveSelect.update_options/2 230 | * add debounce option 231 | * add search delay option to showcase app 232 | * JSON-encode option values before assigning them to the hidden input field 233 | * add LiveSelect.ChangeMsg struct to be used as change message 234 | 235 | ## 0.1.2 (2022-08-10) 236 | 237 | * Disable input field via options 238 | * Placeholder text via options 239 | * Improve docs and showcase app 240 | * Remove setting component id via options 241 | 242 | ### Bugfixes 243 | 244 | * Use atoms as field names, because strings are not accepted by Ecto forms 245 | 246 | ## 0.1.1 (2022-08-09) 247 | 248 | * Remove all colors from default daisyui styles 249 | * Improve styling of showcase app 250 | * Improve docs 251 | * Remove the `msg_prefix` option in favor of `change_msg` 252 | 253 | ## 0.1.0 (2022-08-09) 254 | 255 | First version 🎉 256 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of 2 | # Alpine to avoid DNS resolution issues in production. 3 | # 4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 5 | # https://hub.docker.com/_/ubuntu?tab=tags 6 | # 7 | # 8 | # This file is based on these images: 9 | # 10 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 11 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20220801-slim - for the release image 12 | # - https://pkgs.org/ - resource for finding needed packages 13 | # - Ex: hexpm/elixir:1.14.0-erlang-25.1.1-debian-bullseye-20220801-slim 14 | # 15 | ARG ELIXIR_VERSION=1.15.5 16 | ARG OTP_VERSION=26.1 17 | ARG DEBIAN_VERSION=bullseye-20230612-slim 18 | 19 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" 20 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" 21 | 22 | FROM ${BUILDER_IMAGE} as builder 23 | 24 | ARG NODE_VERSION=18.16.0 25 | 26 | # install build dependencies 27 | RUN apt-get update -y && apt-get install -y build-essential git curl xz-utils\ 28 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 29 | 30 | # install nodejs and yarn 31 | RUN mkdir node \ 32 | && curl https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz | tar -Jx --strip-components 1 -C node \ 33 | && ln -s /node/bin/node /usr/local/bin/node \ 34 | && ln -s /node/bin/npm /usr/local/bin/npm 35 | 36 | RUN npm install -g yarn && ln -s /node/bin/yarn /usr/local/bin/yarn 37 | 38 | # prepare build dir 39 | WORKDIR /app 40 | 41 | # install hex + rebar 42 | RUN mix local.hex --force && \ 43 | mix local.rebar --force 44 | 45 | # set build ENV 46 | ENV MIX_ENV="demo" 47 | 48 | # install mix dependencies 49 | COPY mix.exs mix.lock ./ 50 | RUN mix deps.get --only ${MIX_ENV} 51 | RUN mkdir config 52 | 53 | # copy compile-time config files before we compile dependencies 54 | # to ensure any relevant config change will trigger the dependencies 55 | # to be re-compiled. 56 | COPY config/config.exs config/${MIX_ENV}.exs config/ 57 | RUN mix deps.compile 58 | 59 | COPY priv priv 60 | 61 | COPY lib lib 62 | 63 | COPY assets assets 64 | 65 | # compile assets 66 | RUN mix assets.deploy 67 | 68 | # Compile the release 69 | RUN mix compile 70 | 71 | # Changes to config/runtime.exs don't require recompiling the code 72 | COPY config/runtime.exs config/ 73 | 74 | COPY rel rel 75 | RUN mix release 76 | 77 | # start a new build stage so that the final image will only contain 78 | # the compiled release and other runtime necessities 79 | FROM ${RUNNER_IMAGE} 80 | 81 | RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \ 82 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 83 | 84 | # Set the locale 85 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 86 | 87 | ENV LANG en_US.UTF-8 88 | ENV LANGUAGE en_US:en 89 | ENV LC_ALL en_US.UTF-8 90 | 91 | WORKDIR "/app" 92 | RUN chown nobody /app 93 | 94 | # set runner ENV 95 | ENV MIX_ENV="demo" 96 | 97 | # Only copy the final release from the build stage 98 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/live_select ./ 99 | 100 | USER nobody 101 | 102 | CMD ["/app/bin/server"] 103 | # Appended by flyctl 104 | ENV ECTO_IPV6 true 105 | ENV ERL_AFLAGS "-proto_dist inet6_tcp" 106 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LiveSelect 2 | 3 | [![Hex](https://img.shields.io/hexpm/v/live_select.svg)](https://hex.pm/packages/live_select) 4 | [![Hexdocs](https://img.shields.io/badge/-docs-green)](https://hexdocs.pm/live_select) 5 | [![Elixir CI](https://github.com/maxmarcon/live_select/actions/workflows/elixir.yml/badge.svg)](https://github.com/maxmarcon/live_select/actions/workflows/elixir.yml) 6 | 7 | Dynamic (multi)selection field for LiveView. 8 | 9 | `LiveSelect` is a LiveView component that implements a dynamic selection field with a dropdown. The content of the 10 | dropdown is filled by your LiveView as the user types. This allows you to easily create an 11 | interface for search-like functionalities with type-ahead. `LiveSelect`s features include: 12 | 13 | * Single as well as multiple selection 14 | * Options to configure the behaviour, such as minimum number of characters that trigger an update or the maximum number of selectable options 15 | * Default styles for daisyUI and tailwindcss, which are fully customizable and can be completely overridden if needed 16 | * Ability to customize the rendered HTML for dropdown entries and tags using slots. 17 | 18 | ### [Try it in the showcase app](https://live-select.fly.dev/) 🔬 19 | 20 | ### Single selection (single mode) 21 | 22 | ![DEMO](https://raw.githubusercontent.com/maxmarcon/live_select/main/priv/static/images/demo_single.gif) 23 | 24 | ### Multiple selection (tags mode) 25 | 26 | ![DEMO](https://raw.githubusercontent.com/maxmarcon/live_select/main/priv/static/images/demo_tags.gif) 27 | 28 | ### Multiple selection (quick_tags mode) 29 | 30 | ![DEMO](https://raw.githubusercontent.com/maxmarcon/live_select/main/priv/static/images/demo_quick_tags.gif) 31 | 32 | ## Usage Example 🧭 33 | 34 | _Template:_ 35 | 36 | ```elixir 37 | <.form for={@form} phx-change="change"> 38 | <.live_select field={@form[:city_search]} /> 39 | 40 | ``` 41 | 42 | **NOTE:** If your form is implemented in a LiveComponent, add `phx-target={@myself}`, like this: 43 | 44 | ```elixir 45 | <.live_select field={@form[:city_search]} phx-target={@myself} /> 46 | ``` 47 | 48 | _In the LiveView or LiveComponent that's the target of your form events:_ 49 | 50 | ```elixir 51 | @impl true 52 | def handle_event("live_select_change", %{"text" => text, "id" => live_select_id}, socket) do 53 | cities = City.search(text) 54 | # cities = [ 55 | # {"New York City", [-74.00597,40.71427]}, 56 | # {"New Kingston", [-76.78319,18.00747]}, 57 | # ... 58 | # ] 59 | 60 | send_update(LiveSelect.Component, id: live_select_id, options: cities) 61 | 62 | {:noreply, socket} 63 | end 64 | 65 | @impl true 66 | def handle_event( 67 | "change", 68 | %{"my_form" => %{"city_search_text_input" => city_name, "city_search" => city_coords}}, 69 | socket 70 | ) do 71 | IO.puts("You selected city #{city_name} located at: #{city_coords}") 72 | 73 | {:noreply, socket} 74 | end 75 | ``` 76 | 77 | Refer to the [module documentation](https://hexdocs.pm/live_select/LiveSelect.html) for the details, and 78 | check out the [cheatsheet](https://hexdocs.pm/live_select/cheatsheet.html) for some useful tips. 79 | 80 | ## Installation 📦 81 | 82 | To install, add this to your dependencies: 83 | 84 | ```elixir 85 | [ 86 | {:live_select, "~> 1.0"} 87 | ] 88 | ``` 89 | 90 | ## Javascript hooks 🪝 91 | 92 | `LiveSelect` relies on Javascript hooks to work. You need to add `LiveSelect`'s hooks to your live socket. 93 | `LiveSelect` distributes its Javascript code (a single file) in the same way as LiveView, by including an 94 | npm package as part of its hex package. 95 | 96 | To include `LiveSelect`'s hooks, add this to your `app.js` file: 97 | 98 | ```javascript 99 | import live_select from "live_select" 100 | 101 | // if you don't have any other hooks: 102 | let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: live_select}) 103 | 104 | // if you have other hooks: 105 | const hooks = { 106 | MyHook: { 107 | // ... 108 | }, 109 | ...live_select 110 | } 111 | let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks}) 112 | ``` 113 | 114 | ### If you're using Webpack or another NPM-based builder 115 | 116 | If you're using an npm-based builder such as Webpack, you will need to add `LiveSelect` to the list of your dependencies in your `package.json` (just as you did with LiveView): 117 | 118 | ```js 119 | { 120 | "dependencies": { 121 | "phoenix": "file:../deps/phoenix", 122 | "phoenix_html": "file:../deps/phoenix_html", 123 | "phoenix_live_view": "file:../deps/phoenix_live_view", 124 | "live_select": "file:../deps/live_select" // <-- add this line, and add an extra "../" if you're in an umbrella app 125 | } 126 | } 127 | ``` 128 | 129 | And then run `npm install` from your `assets` folder. You will also need to run `npm install --force live_select` 130 | whenever you update the `LiveSelect` hex package in order to get the latest JS code. 131 | 132 | ## Styling 🎨 133 | 134 | `LiveSelect` supports 3 styling modes: 135 | 136 | * `tailwind`: uses standard tailwind utility classes (the default) 137 | * `daisyui`: uses [daisyUI](https://daisyui.com/) classes. 138 | * `none`: no styling at all. 139 | 140 | The choice of style is controlled by the `style` option 141 | in [live_select/1](https://hexdocs.pm/live_select/LiveSelect.html#live_select/1). 142 | `tailwind` and `daisyui` styles come with sensible defaults which can be selectively extended or completely overridden. 143 | 144 | Refer to the [Styling section](https://hexdocs.pm/live_select/styling.html) for further details. 145 | 146 | > ⚠️ **Attention** 147 | > 148 | > Please note the different paths for a standalone or umbrella app. 149 | 150 | ### tailwind v3 151 | 152 | If you're using `tailwind` or `daisyui` styles, you need to add one of the following lines to the `content` section in 153 | your `tailwind.config.js`: 154 | 155 | ```javascript 156 | module.exports = { 157 | content: [ 158 | //... 159 | '../deps/live_select/lib/live_select/component.*ex', // <-- for a standalone app 160 | '../../../deps/live_select/lib/live_select/component.*ex' // <-- for an umbrella app 161 | ] 162 | //.. 163 | } 164 | ``` 165 | 166 | ### tailwind v4 167 | 168 | If you are using `tailwind v4+` and are not using a `tailwind.config.js` file you instead need to add the relevant `@source` directive to your `app.css` file: 169 | 170 | ```css 171 | @source "../../deps/live_select/lib/live_select/component.*ex" /* for a standalone app */ 172 | @source "../../../../deps/live_select/lib/live_select/component.*ex" /* for an umbrella app */ 173 | ``` 174 | 175 | 176 | 177 | 178 | ## Showcase app 🎪 179 | 180 | The repository includes a showcase app that you can use to experiment with the different options and parameters 181 | for `LiveSelect`. 182 | The showcase app is available [here](https://live-select.fly.dev/). 183 | 184 | To start the showcase app locally, simply run: 185 | 186 | ``` 187 | mix setup 188 | PORT=4001 mix phx.server 189 | ``` 190 | 191 | from within the cloned repository. The app will be available at http://localhost:4001. The showcase app allows you to 192 | quickly experiment with options and styles, providing an easy way to fine tune your `LiveSelect` component. The app also 193 | shows the messages and events that your `LiveView` receives. For each event or message, the app shows the function head 194 | of the callback that your LiveView needs to implement in order to handle the event. 195 | 196 | ## Contribute 🤝 197 | 198 | Contributions are very welcome! However, if you want do add a new feature please discuss it first by creating an issue so we can all agree that it's needed. 199 | Also, it's important to add a test that covers it. If you don't know how to write the test or need guidance, 200 | I'm happy to help. 201 | 202 | Use `mix test` to run the entire test suite, which is subdivided into 3 main files: 203 | 204 | * `test/live_select/component_test.exs` - everything that can be tested by rendering the component statically 205 | * `test/live_select_test.exs` - tests for `single` mode that require a running LiveView 206 | * `test/live_select_tags_test.exs` - tests for `tags` mode that require a running LiveView 207 | 208 | Tests that require a LiveView use the showcase app as the parent LiveView. 209 | 210 | ## Roadmap 🛣️ 211 | 212 | - [X] Add `package.json` to enable `import live_select from "live_select"` 213 | - [X] Make sure component classes are included by tailwind 214 | - [X] Enable custom styling 215 | - [X] Rename LiveSelect.render to live_select 216 | - [X] Customizable placeholder 217 | - [X] Enable configuration of styles in the showcase app 218 | - [X] Add support for vanilla tailwind styles 219 | - [X] Enable multiple selection mode 220 | - [X] Expose as function component (and drop LV 0.17 support) 221 | - [X] Add cheatsheet 222 | - [X] Additional multiple selection mode 223 | - [ ] Add section to document testing strategies 224 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | /* This file is for your main application CSS */ 6 | .alert:empty { 7 | display: none; 8 | } 9 | 10 | .invalid-feedback { 11 | @apply label text-error text-sm 12 | } 13 | 14 | LiveView specific classes for your customization 15 | .phx-no-feedback.invalid-feedback, 16 | .phx-no-feedback .invalid-feedback { 17 | display: block; 18 | } 19 | 20 | .phx-click-loading { 21 | opacity: 0.5; 22 | transition: opacity 1s ease-out; 23 | } 24 | 25 | .phx-loading { 26 | cursor: wait; 27 | } 28 | 29 | .phx-modal { 30 | opacity: 1 !important; 31 | position: fixed; 32 | z-index: 1; 33 | left: 0; 34 | top: 0; 35 | width: 100%; 36 | height: 100%; 37 | overflow: auto; 38 | background-color: rgba(0, 0, 0, 0.4); 39 | } 40 | 41 | .phx-modal-content { 42 | background-color: #fefefe; 43 | margin: 15vh auto; 44 | padding: 20px; 45 | border: 1px solid #888; 46 | width: 80%; 47 | } 48 | 49 | .phx-modal-close { 50 | color: #aaa; 51 | float: right; 52 | font-size: 28px; 53 | font-weight: bold; 54 | } 55 | 56 | .phx-modal-close:hover, 57 | .phx-modal-close:focus { 58 | color: black; 59 | text-decoration: none; 60 | cursor: pointer; 61 | } 62 | 63 | .fade-in-scale { 64 | animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; 65 | } 66 | 67 | .fade-out-scale { 68 | animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; 69 | } 70 | 71 | .fade-in { 72 | animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; 73 | } 74 | 75 | .fade-out { 76 | animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys; 77 | } 78 | 79 | @keyframes fade-in-scale-keys { 80 | 0% { 81 | scale: 0.95; 82 | opacity: 0; 83 | } 84 | 100% { 85 | scale: 1.0; 86 | opacity: 1; 87 | } 88 | } 89 | 90 | @keyframes fade-out-scale-keys { 91 | 0% { 92 | scale: 1.0; 93 | opacity: 1; 94 | } 95 | 100% { 96 | scale: 0.95; 97 | opacity: 0; 98 | } 99 | } 100 | 101 | @keyframes fade-in-keys { 102 | 0% { 103 | opacity: 0; 104 | } 105 | 100% { 106 | opacity: 1; 107 | } 108 | } 109 | 110 | @keyframes fade-out-keys { 111 | 0% { 112 | opacity: 1; 113 | } 114 | 100% { 115 | opacity: 0; 116 | } 117 | } 118 | 119 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // We import the CSS which is extracted to its own file by esbuild. 2 | // Remove this line if you add a your own CSS build pipeline (e.g postcss). 3 | 4 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 5 | // to get started and then uncomment the line below. 6 | // import "./user_socket.js" 7 | 8 | // You can include dependencies in two ways. 9 | // 10 | // The simplest option is to put them in assets/vendor and 11 | // import them using relative paths: 12 | // 13 | // import "../vendor/some-package.js" 14 | // 15 | // Alternatively, you can `npm install some-package --prefix assets` and import 16 | // them using a path starting with the package name: 17 | // 18 | // import "some-package" 19 | // 20 | 21 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 22 | import "phoenix_html" 23 | // Establish Phoenix Socket and LiveView configuration. 24 | import {Socket} from "phoenix" 25 | import {LiveSocket} from "phoenix_live_view" 26 | import live_select from "./live_select"; 27 | import topbar from "../vendor/topbar" 28 | import ClipboardJS from "clipboard"; 29 | import {themeChange} from 'theme-change' 30 | 31 | const initialTheme = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? "dark" : "light" 32 | 33 | localStorage.setItem("theme", initialTheme) 34 | 35 | themeChange() 36 | 37 | const hooks = { 38 | watchThemeChanges: { 39 | mounted() { 40 | this.setDarkMode() 41 | this.el.onclick = () => this.setDarkMode() 42 | }, 43 | setDarkMode() { 44 | const theme = localStorage.getItem("theme") 45 | this.pushEvent("dark-mode", theme === 'dark') 46 | } 47 | }, 48 | ...live_select 49 | } 50 | 51 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 52 | let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks}) 53 | let clipboard = new ClipboardJS("button#copy-to-clipboard") 54 | clipboard.on("success", () => { 55 | const tooltip = document.querySelector("#copy-to-clipboard-tooltip") 56 | tooltip.classList.add("tooltip", "tooltip-open") 57 | setTimeout(() => tooltip.classList.remove("tooltip", "tooltip-open"), 1000) 58 | }) 59 | 60 | // Show progress bar on live navigation and form submits 61 | topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) 62 | window.addEventListener("phx:page-loading-start", info => topbar.show()) 63 | window.addEventListener("phx:page-loading-stop", info => topbar.hide()) 64 | 65 | // connect if there are any LiveViews on the page 66 | liveSocket.connect() 67 | 68 | // expose liveSocket on window for web console debug logs and latency simulation: 69 | // >> liveSocket.enableDebug() 70 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 71 | // >> liveSocket.disableLatencySim() 72 | window.liveSocket = liveSocket 73 | 74 | -------------------------------------------------------------------------------- /assets/js/live_select.js: -------------------------------------------------------------------------------- 1 | function debounce(func, msec) { 2 | let timer; 3 | return (...args) => { 4 | clearTimeout(timer) 5 | timer = setTimeout(() => { 6 | func.apply(this, args) 7 | }, msec) 8 | } 9 | } 10 | 11 | export default { 12 | LiveSelect: { 13 | textInput() { 14 | return this.el.querySelector("input[type=text]") 15 | }, 16 | debounceMsec() { 17 | return parseInt(this.el.dataset["debounce"]) 18 | }, 19 | updateMinLen() { 20 | return parseInt(this.el.dataset["updateMinLen"]) 21 | }, 22 | maybeStyleClearButton() { 23 | const clear_button = this.el.querySelector('button[phx-click=clear]') 24 | if (clear_button) { 25 | this.textInput().parentElement.style.position = 'relative' 26 | clear_button.style.position = 'absolute' 27 | clear_button.style.top = '0px' 28 | clear_button.style.bottom = '0px' 29 | clear_button.style.right = '5px' 30 | clear_button.style.display = 'block' 31 | } 32 | }, 33 | pushEventToParent(event, payload) { 34 | const target = this.el.dataset['phxTarget']; 35 | if (target) { 36 | this.pushEventTo(target, event, payload) 37 | } else { 38 | this.pushEvent(event, payload) 39 | } 40 | }, 41 | attachDomEventHandlers() { 42 | this.textInput().onkeydown = (event) => { 43 | if (event.code === "Enter") { 44 | event.preventDefault() 45 | } 46 | this.pushEventTo(this.el, 'keydown', {key: event.code}) 47 | } 48 | this.changeEvents = debounce((id, field, text) => { 49 | this.pushEventTo(this.el, "change", {text}) 50 | this.pushEventToParent("live_select_change", {id: this.el.id, field, text}) 51 | }, this.debounceMsec()) 52 | this.textInput().oninput = (event) => { 53 | const text = event.target.value.trim() 54 | const field = this.el.dataset['field'] 55 | if (text.length >= this.updateMinLen()) { 56 | this.changeEvents(this.el.id, field, text) 57 | } else { 58 | this.pushEventTo(this.el, "options_clear", {}) 59 | } 60 | } 61 | const dropdown = this.el.querySelector("ul") 62 | if (dropdown) { 63 | dropdown.onmousedown = (event) => { 64 | const option = event.target.closest('div[data-idx]') 65 | if (option) { 66 | this.pushEventTo(this.el, 'option_click', {idx: option.dataset.idx}) 67 | event.preventDefault() 68 | } 69 | } 70 | } 71 | this.el.querySelectorAll("button[data-idx]").forEach(button => { 72 | button.onclick = (event) => { 73 | this.pushEventTo(this.el, 'option_remove', {idx: button.dataset.idx}) 74 | } 75 | }) 76 | }, 77 | setInputValue(value) { 78 | this.textInput().value = value 79 | }, 80 | inputEvent(selection, mode) { 81 | const selector = mode === "single" ? "input.single-mode" : (selection.length === 0 ? "input[data-live-select-empty]" : "input[type=hidden]") 82 | this.el.querySelector(selector).dispatchEvent(new Event('input', {bubbles: true})) 83 | }, 84 | mounted() { 85 | this.maybeStyleClearButton() 86 | this.handleEvent("parent_event", ({id, event, payload}) => { 87 | if (this.el.id === id) { 88 | this.pushEventToParent(event, payload) 89 | } 90 | }) 91 | this.handleEvent("select", ({id, selection, mode, current_text, input_event, parent_event}) => { 92 | if (this.el.id === id) { 93 | this.selection = selection 94 | if (mode === "single") { 95 | const label = selection.length > 0 ? selection[0].label : current_text 96 | this.setInputValue(label) 97 | } else { 98 | this.setInputValue(current_text) 99 | } 100 | if (input_event) { 101 | this.inputEvent(selection, mode) 102 | } 103 | if (parent_event) { 104 | this.pushEventToParent(parent_event, {id}) 105 | } 106 | } 107 | }) 108 | this.handleEvent("active", ({id, idx}) => { 109 | if (this.el.id === id) { 110 | const option = this.el.querySelector(`div[data-idx="${idx}"]`) 111 | if (option) { 112 | option.scrollIntoView({block: "nearest"}) 113 | } 114 | } 115 | }) 116 | this.attachDomEventHandlers() 117 | }, 118 | updated() { 119 | this.maybeStyleClearButton() 120 | this.attachDomEventHandlers() 121 | }, 122 | reconnected() { 123 | if (this.selection && this.selection.length > 0) { 124 | this.pushEventTo(this.el.id, "selection_recovery", this.selection) 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@tailwindcss/forms": "^0.5.3", 4 | "@tailwindcss/typography": "^0.5.2", 5 | "clipboard": "^2.0.11", 6 | "daisyui": "^3.9.3", 7 | "theme-change": "^2.2.0" 8 | }, 9 | "dependencies": {} 10 | } 11 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | module.exports = { 4 | content: [ 5 | './js/**/*.js', 6 | '../config/*.exs', 7 | '../lib/support/*_web.ex', 8 | '../lib/support/*_web/**/*.*ex', 9 | '../lib/live_select/*.*ex' 10 | ], 11 | safelist: [ 12 | { 13 | pattern: /./ 14 | } 15 | ], 16 | theme: { 17 | extend: {}, 18 | }, 19 | darkMode: 'class', 20 | plugins: [ 21 | require("daisyui"), 22 | require('@tailwindcss/typography'), 23 | require('@tailwindcss/forms') 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /assets/vendor/topbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license MIT 3 | * topbar 1.0.0, 2021-01-06 4 | * https://buunguyen.github.io/topbar 5 | * Copyright (c) 2021 Buu Nguyen 6 | */ 7 | (function (window, document) { 8 | "use strict"; 9 | 10 | // https://gist.github.com/paulirish/1579671 11 | (function () { 12 | var lastTime = 0; 13 | var vendors = ["ms", "moz", "webkit", "o"]; 14 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { 15 | window.requestAnimationFrame = 16 | window[vendors[x] + "RequestAnimationFrame"]; 17 | window.cancelAnimationFrame = 18 | window[vendors[x] + "CancelAnimationFrame"] || 19 | window[vendors[x] + "CancelRequestAnimationFrame"]; 20 | } 21 | if (!window.requestAnimationFrame) 22 | window.requestAnimationFrame = function (callback, element) { 23 | var currTime = new Date().getTime(); 24 | var timeToCall = Math.max(0, 16 - (currTime - lastTime)); 25 | var id = window.setTimeout(function () { 26 | callback(currTime + timeToCall); 27 | }, timeToCall); 28 | lastTime = currTime + timeToCall; 29 | return id; 30 | }; 31 | if (!window.cancelAnimationFrame) 32 | window.cancelAnimationFrame = function (id) { 33 | clearTimeout(id); 34 | }; 35 | })(); 36 | 37 | var canvas, 38 | progressTimerId, 39 | fadeTimerId, 40 | currentProgress, 41 | showing, 42 | addEvent = function (elem, type, handler) { 43 | if (elem.addEventListener) elem.addEventListener(type, handler, false); 44 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler); 45 | else elem["on" + type] = handler; 46 | }, 47 | options = { 48 | autoRun: true, 49 | barThickness: 3, 50 | barColors: { 51 | 0: "rgba(26, 188, 156, .9)", 52 | ".25": "rgba(52, 152, 219, .9)", 53 | ".50": "rgba(241, 196, 15, .9)", 54 | ".75": "rgba(230, 126, 34, .9)", 55 | "1.0": "rgba(211, 84, 0, .9)", 56 | }, 57 | shadowBlur: 10, 58 | shadowColor: "rgba(0, 0, 0, .6)", 59 | className: null, 60 | }, 61 | repaint = function () { 62 | canvas.width = window.innerWidth; 63 | canvas.height = options.barThickness * 5; // need space for shadow 64 | 65 | var ctx = canvas.getContext("2d"); 66 | ctx.shadowBlur = options.shadowBlur; 67 | ctx.shadowColor = options.shadowColor; 68 | 69 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); 70 | for (var stop in options.barColors) 71 | lineGradient.addColorStop(stop, options.barColors[stop]); 72 | ctx.lineWidth = options.barThickness; 73 | ctx.beginPath(); 74 | ctx.moveTo(0, options.barThickness / 2); 75 | ctx.lineTo( 76 | Math.ceil(currentProgress * canvas.width), 77 | options.barThickness / 2 78 | ); 79 | ctx.strokeStyle = lineGradient; 80 | ctx.stroke(); 81 | }, 82 | createCanvas = function () { 83 | canvas = document.createElement("canvas"); 84 | var style = canvas.style; 85 | style.position = "fixed"; 86 | style.top = style.left = style.right = style.margin = style.padding = 0; 87 | style.zIndex = 100001; 88 | style.display = "none"; 89 | if (options.className) canvas.classList.add(options.className); 90 | document.body.appendChild(canvas); 91 | addEvent(window, "resize", repaint); 92 | }, 93 | topbar = { 94 | config: function (opts) { 95 | for (var key in opts) 96 | if (options.hasOwnProperty(key)) options[key] = opts[key]; 97 | }, 98 | show: function () { 99 | if (showing) return; 100 | showing = true; 101 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); 102 | if (!canvas) createCanvas(); 103 | canvas.style.opacity = 1; 104 | canvas.style.display = "block"; 105 | topbar.progress(0); 106 | if (options.autoRun) { 107 | (function loop() { 108 | progressTimerId = window.requestAnimationFrame(loop); 109 | topbar.progress( 110 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) 111 | ); 112 | })(); 113 | } 114 | }, 115 | progress: function (to) { 116 | if (typeof to === "undefined") return currentProgress; 117 | if (typeof to === "string") { 118 | to = 119 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 120 | ? currentProgress 121 | : 0) + parseFloat(to); 122 | } 123 | currentProgress = to > 1 ? 1 : to; 124 | repaint(); 125 | return currentProgress; 126 | }, 127 | hide: function () { 128 | if (!showing) return; 129 | showing = false; 130 | if (progressTimerId != null) { 131 | window.cancelAnimationFrame(progressTimerId); 132 | progressTimerId = null; 133 | } 134 | (function loop() { 135 | if (topbar.progress("+.1") >= 1) { 136 | canvas.style.opacity -= 0.05; 137 | if (canvas.style.opacity <= 0.05) { 138 | canvas.style.display = "none"; 139 | fadeTimerId = null; 140 | return; 141 | } 142 | } 143 | fadeTimerId = window.requestAnimationFrame(loop); 144 | })(); 145 | }, 146 | }; 147 | 148 | if (typeof module === "object" && typeof module.exports === "object") { 149 | module.exports = topbar; 150 | } else if (typeof define === "function" && define.amd) { 151 | define(function () { 152 | return topbar; 153 | }); 154 | } else { 155 | this.topbar = topbar; 156 | } 157 | }.call(this, window, document)); 158 | -------------------------------------------------------------------------------- /cheatsheet.cheatmd: -------------------------------------------------------------------------------- 1 | ## Style `LiveSelect` like a Phoenix Core Component, with label and errors 2 | 3 | #### 1. Add this to `core_components.ex`: 4 | 5 | ```elixir 6 | def live_select(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do 7 | assigns = 8 | assigns 9 | |> assign(:errors, Enum.map(field.errors, &translate_error(&1))) 10 | |> assign(:live_select_opts, assigns_to_attributes(assigns, [:errors, :label])) 11 | 12 | ~H""" 13 |
14 | <.label for={@field.id}><%= @label %> 15 | 26 | 27 | <.error :for={msg <- @errors}><%= msg %> 28 |
29 | """ 30 | end 31 | ``` 32 | 33 | #### 2. Then call it this way: 34 | 35 | ```elixir 36 | <.live_select field={@form[:city]} label="City" phx-target={@myself} /> 37 | ``` 38 | 39 | You can also pass any of the other `LiveSelect` options. 40 | 41 | ## Implementing a simple search functionality 42 | 43 | ### 1. With no options displayed if the user doesn't enter any text 44 | 45 | #### Heex template: 46 | 47 | ```elixir 48 | <.live_select field={@form[:locations]} update_min_len={1} phx-focus="clear" /> 49 | ``` 50 | 51 | #### Live view: 52 | 53 | ```elixir 54 | @impl true 55 | def handle_event("live_select_change", %{"id" => id, "text" => text}, socket) do 56 | options = 57 | retrieve_locations() 58 | |> Enum.filter(&(String.downcase(&1) |> String.contains?(String.downcase(text)))) 59 | 60 | send_update(LiveSelect.Component, options: options, id: id) 61 | 62 | {:noreply, socket} 63 | end 64 | 65 | @impl true 66 | def handle_event("clear", %{"id" => id}, socket) do 67 | send_update(LiveSelect.Component, options: [], id: id) 68 | 69 | {:noreply, socket} 70 | end 71 | ``` 72 | 73 | ### 2. With a fixed set of default options to be displayed when the text input is empty 74 | 75 | #### Heex template: 76 | 77 | ```elixir 78 | <.live_select field={@form[:locations]} update_min_len={0} phx-focus="set-default" options={@default_locations} /> 79 | ``` 80 | 81 | #### Live view: 82 | 83 | ```elixir 84 | @impl true 85 | def mount(socket) do 86 | socket = assign(socket, default_locations: default_locations()) 87 | 88 | {:ok, socket} 89 | end 90 | 91 | @impl true 92 | def handle_event("live_select_change", %{"id" => id, "text" => text}, socket) do 93 | options = 94 | if text == "" do 95 | socket.assigns.default_locations 96 | else 97 | retrieve_locations() 98 | |> Enum.filter(&(String.downcase(&1) |> String.contains?(String.downcase(text)))) 99 | end 100 | 101 | send_update(LiveSelect.Component, options: options, id: id) 102 | 103 | {:noreply, socket} 104 | end 105 | 106 | @impl true 107 | def handle_event("set-default", %{"id" => id}, socket) do 108 | send_update(LiveSelect.Component, options: socket.assigns.default_locations, id: id) 109 | 110 | {:noreply, socket} 111 | end 112 | ``` 113 | 114 | ## Dropdown that opens above the input field 115 | {: .col-2} 116 | 117 | ### Tailwind 118 | 119 | #### Heex template 120 | 121 | ```elixir 122 | <.live_select 123 | field={@form[:my_field]} 124 | dropdown_extra_class="!top-full bottom-full" /> 125 | ``` 126 | 127 | ### DaisyUI 128 | 129 | #### Heex template 130 | 131 | ```elixir 132 | <.live_select 133 | field={@form[:my_field]} 134 | style={:daisyui} 135 | container_extra_class="dropdown-top" /> 136 | ``` 137 | 138 | ## Display tags underneath the input field (Tailwind) 139 | 140 | #### Heex template: 141 | 142 | ```elixir 143 | <.live_select 144 | field={@form[:my_field]} 145 | mode={:tags} 146 | container_extra_class="flex flex-col" 147 | dropdown_extra_class="top-11" 148 | tags_container_extra_class="order-last" /> 149 | ``` 150 | 151 | ## Limit the height of the dropdown and make it scrollable (Tailwind) 152 | 153 | #### Heex template: 154 | 155 | ``` 156 | <.live_select 157 | field={@form[:my_field]} 158 | dropdown_extra_class="max-h-60 overflow-y-scroll" /> 159 | ``` 160 | 161 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :live_select, :start_application, true 11 | 12 | config :live_select, :change_event_handler, LiveSelect.ChangeEventHandler 13 | 14 | # Configures the endpoint 15 | config :live_select, LiveSelectWeb.Endpoint, 16 | url: [host: "localhost"], 17 | render_errors: [ 18 | formats: [html: LiveSelectWeb.ErrorHTML, json: LiveSelectWeb.ErrorJSON], 19 | layout: false 20 | ], 21 | pubsub_server: LiveSelect.PubSub, 22 | live_view: [signing_salt: "yxyt7t35"] 23 | 24 | # Configures Elixir's Logger 25 | config :logger, :console, 26 | format: "$time $metadata[$level] $message\n", 27 | metadata: [:request_id] 28 | 29 | # Configure esbuild (the version is required) 30 | config :esbuild, 31 | version: "0.14.29", 32 | default: [ 33 | args: 34 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 35 | cd: Path.expand("../assets", __DIR__), 36 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 37 | ], 38 | package: [ 39 | args: 40 | ~w(js/live_select.js --target=es2017 --minify --outfile=../priv/static/live_select.min.js), 41 | cd: Path.expand("../assets", __DIR__), 42 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 43 | ] 44 | 45 | config :tailwind, 46 | version: "3.3.3", 47 | default: [ 48 | args: ~w( 49 | --config=tailwind.config.js 50 | --input=css/app.css 51 | --output=../priv/static/assets/app.css 52 | ), 53 | cd: Path.expand("../assets", __DIR__) 54 | ] 55 | 56 | config :mix, colors: [enabled: true] 57 | 58 | # Import environment specific config. This must remain at the bottom 59 | # of this file so it overrides the configuration defined above. 60 | import_config "#{config_env()}.exs" 61 | -------------------------------------------------------------------------------- /config/demo.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | # 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :live_select, LiveSelectWeb.Endpoint, 13 | cache_static_manifest: "priv/static/cache_manifest.json" 14 | 15 | # Do not print debug messages in production 16 | config :logger, level: :info 17 | 18 | # ## SSL Support 19 | # 20 | # To get SSL working, you will need to add the `https` key 21 | # to the previous section and set your `:url` port to 443: 22 | # 23 | # config :live_select, LiveSelectWeb.Endpoint, 24 | # ..., 25 | # url: [host: "example.com", port: 443], 26 | # https: [ 27 | # ..., 28 | # port: 443, 29 | # cipher_suite: :strong, 30 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 31 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 32 | # ] 33 | # 34 | # The `cipher_suite` is set to `:strong` to support only the 35 | # latest and more secure SSL ciphers. This means old browsers 36 | # and clients may not be supported. You can set it to 37 | # `:compatible` for wider support. 38 | # 39 | # `:keyfile` and `:certfile` expect an absolute path to the key 40 | # and cert in disk or a relative path inside priv, for example 41 | # "priv/ssl/server.key". For all supported SSL configuration 42 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 43 | # 44 | # We also recommend setting `force_ssl` in your endpoint, ensuring 45 | # no data is ever sent via http, always redirecting to https: 46 | # 47 | # config :live_select, LiveSelectWeb.Endpoint, 48 | # force_ssl: [hsts: true] 49 | # 50 | # Check `Plug.SSL` for all available options in `force_ssl`. 51 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with esbuild to bundle .js and .css sources. 9 | config :live_select, LiveSelectWeb.Endpoint, 10 | # Binding to loopback ipv4 address prevents access from other machines. 11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 12 | http: [ip: {127, 0, 0, 1}, port: 4000], 13 | check_origin: false, 14 | code_reloader: true, 15 | debug_errors: true, 16 | secret_key_base: "2nJhi0qF4Z8rVEW1MahlKTpKsfE/IqlM0/sxyd9S98q/96ZiWJOpCkLHd5/cmsz5", 17 | watchers: [ 18 | # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) 19 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, 20 | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} 21 | ] 22 | 23 | # ## SSL Support 24 | # 25 | # In order to use HTTPS in development, a self-signed 26 | # certificate can be generated by running the following 27 | # Mix task: 28 | # 29 | # mix phx.gen.cert 30 | # 31 | # Note that this task requires Erlang/OTP 20 or later. 32 | # Run `mix help phx.gen.cert` for more information. 33 | # 34 | # The `http:` config above can be replaced with: 35 | # 36 | # https: [ 37 | # port: 4001, 38 | # cipher_suite: :strong, 39 | # keyfile: "priv/cert/selfsigned_key.pem", 40 | # certfile: "priv/cert/selfsigned.pem" 41 | # ], 42 | # 43 | # If desired, both `http:` and `https:` keys can be 44 | # configured to run both http and https servers on 45 | # different ports. 46 | 47 | # Watch static and templates for browser reloading. 48 | config :live_select, LiveSelectWeb.Endpoint, 49 | live_reload: [ 50 | patterns: [ 51 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 52 | ~r"lib/.*(ex)$" 53 | ] 54 | ] 55 | 56 | # Do not include metadata nor timestamps in development logs 57 | config :logger, :console, format: "[$level] $message\n" 58 | 59 | # Set a higher stacktrace during development. Avoid configuring such 60 | # in production as building large stacktraces may be expensive. 61 | config :phoenix, :stacktrace_depth, 20 62 | 63 | # Initialize plugs at runtime for faster development compilation 64 | config :phoenix, :plug_init_mode, :runtime 65 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/live_select start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | if System.get_env("PHX_SERVER") do 20 | config :live_select, LiveSelectWeb.Endpoint, server: true 21 | end 22 | 23 | port = String.to_integer(System.get_env("PORT") || "4000") 24 | config :live_select, LiveSelectWeb.Endpoint, http: [port: port] 25 | 26 | current_semver = Version.parse!(System.version()) 27 | min_semver = %Version{major: 1, minor: 18, patch: 0} 28 | 29 | if Version.compare(current_semver, min_semver) == :lt do 30 | # Use Jason for JSON parsing in Phoenix 31 | config :phoenix, :json_library, Jason 32 | else 33 | # Use built-in JSON module for JSON parsing in Phoenix 34 | config :phoenix, :json_library, JSON 35 | end 36 | 37 | if config_env() == :demo do 38 | # The secret key base is used to sign/encrypt cookies and other secrets. 39 | # A default value is used in config/dev.exs and config/test.exs but you 40 | # want to use a different value for prod and you most likely don't want 41 | # to check this value into version control, so we use an environment 42 | # variable instead. 43 | secret_key_base = 44 | System.get_env("SECRET_KEY_BASE") || 45 | raise """ 46 | environment variable SECRET_KEY_BASE is missing. 47 | You can generate one by calling: mix phx.gen.secret 48 | """ 49 | 50 | host = System.get_env("PHX_HOST") || "example.com" 51 | port = String.to_integer(System.get_env("PORT") || "4000") 52 | 53 | config :live_select, LiveSelectWeb.Endpoint, 54 | url: [host: host, port: 443, scheme: "https"], 55 | http: [ 56 | # Enable IPv6 and bind on all interfaces. 57 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 58 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 59 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 60 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 61 | port: port 62 | ], 63 | secret_key_base: secret_key_base 64 | end 65 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :live_select, :change_event_handler, LiveSelect.ChangeEventHandlerMock 4 | 5 | # We don't run a server during test. If one is required, 6 | # you can enable the server option below. 7 | config :live_select, LiveSelectWeb.Endpoint, 8 | http: [ip: {127, 0, 0, 1}, port: 4002], 9 | secret_key_base: "hTnE+M7j7UmlMhdUlCMbqCazLpqij0DnurlfNdhxKTbfaA6C/OHHy09E1hSItIM2", 10 | server: false 11 | 12 | # Print only warnings and errors during test 13 | config :logger, level: :warning 14 | 15 | # Initialize plugs at runtime for faster test compilation 16 | config :phoenix, :plug_init_mode, :runtime 17 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for live-select on 2023-01-14T14:41:18+01:00 2 | 3 | app = "live-select" 4 | kill_signal = "SIGTERM" 5 | kill_timeout = 5 6 | processes = [] 7 | 8 | [env] 9 | PHX_HOST = "live-select.fly.dev" 10 | PORT = "8080" 11 | 12 | [experimental] 13 | auto_rollback = true 14 | 15 | [[services]] 16 | http_checks = [] 17 | internal_port = 8080 18 | processes = ["app"] 19 | protocol = "tcp" 20 | script_checks = [] 21 | [services.concurrency] 22 | hard_limit = 25 23 | soft_limit = 20 24 | type = "connections" 25 | 26 | [[services.ports]] 27 | force_https = true 28 | handlers = ["http"] 29 | port = 80 30 | 31 | [[services.ports]] 32 | handlers = ["tls", "http"] 33 | port = 443 34 | 35 | [[services.tcp_checks]] 36 | grace_period = "1s" 37 | interval = "15s" 38 | restart_limit = 0 39 | timeout = "2s" 40 | -------------------------------------------------------------------------------- /lib/live_select/class_util.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveSelect.ClassUtil do 2 | @moduledoc false 3 | 4 | @doc ~S""" 5 | iex> extend(~W(bg-white text-yellow), ~W(p-2)) 6 | ~W(bg-white text-yellow p-2) 7 | 8 | iex> extend(~W(bg-white text-yellow), ~W(bg-white)) 9 | ~W(bg-white text-yellow) 10 | 11 | iex> extend(~W(bg-white text-yellow), ~W(!text-yellow text-black)) 12 | ~W(bg-white text-black) 13 | 14 | iex> extend(~W(bg-white text-yellow), ~W(!text-yellow text-black !bg-white)) 15 | ~W(text-black) 16 | 17 | iex> extend(~W(bg-white text-yellow), ~W(!text-yellow !bg-white)) 18 | [] 19 | 20 | iex> extend(~W(bg-white text-yellow), []) 21 | ~W(bg-white text-yellow) 22 | 23 | iex> extend([], []) 24 | [] 25 | """ 26 | @spec extend([String.t()], [String.t()]) :: [String.t()] 27 | def extend(base, extend) when is_list(base) and is_list(extend) do 28 | base_classes = 29 | Enum.uniq(base) 30 | |> Enum.filter(& &1) 31 | 32 | {remove, add} = 33 | extend 34 | |> Enum.filter(& &1) 35 | |> Enum.split_with(&String.starts_with?(&1, "!")) 36 | 37 | add = 38 | add 39 | |> Enum.reject(&(&1 in base_classes)) 40 | |> Enum.uniq() 41 | 42 | remove = 43 | remove 44 | |> Enum.map(&String.trim_leading(&1, "!")) 45 | |> Enum.uniq() 46 | 47 | (base_classes -- remove) ++ add 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/live_select/component.html.heex: -------------------------------------------------------------------------------- 1 |
11 |
12 | <%= if (@mode in [:tags, :quick_tags]) && Enum.any?(@selection) do %> 13 | <%= for {option, idx} <- Enum.with_index(@selection) do %> 14 |
15 | <%= if @tag == [] do %> 16 | <%= option[:tag_label] || option[:label] %> 17 | <% else %> 18 | <%= render_slot(@tag, option) %> 19 | <% end %> 20 | 39 |
40 | <% end %> 41 | <% end %> 42 |
43 | 44 |
45 | <%= text_input(@field.form, @text_input_field, 46 | class: 47 | class(@style, :text_input, @text_input_class, @text_input_extra_class) ++ 48 | List.wrap( 49 | if(Enum.any?(@selection), 50 | do: class(@style, :text_input_selected, @text_input_selected_class) 51 | ) 52 | ), 53 | placeholder: @placeholder, 54 | phx_target: @myself, 55 | phx_change: "change", 56 | disabled: @disabled, 57 | autocomplete: "off", 58 | phx_focus: "focus", 59 | phx_click: "click", 60 | phx_blur: "blur", 61 | value: label(@mode, @selection) 62 | ) %> 63 | <%= if @mode == :single && @allow_clear && !@disabled && Enum.any?(@selection) do %> 64 | 76 | <% end %> 77 |
78 | <%= if @mode == :single do %> 79 | <%= hidden_input(@field.form, @field.field, 80 | disabled: @disabled, 81 | class: "single-mode", 82 | value: value(@selection) 83 | ) %> 84 | <% else %> 85 | 86 | <%= if Enum.empty?(@selection) do %> 87 | 94 | <% end %> 95 | <%= for {value, idx} <- values(@selection) |> Enum.with_index() do %> 96 | "[]"} 99 | id={@field.id <> "_#{idx}"} 100 | disabled={@disabled} 101 | value={value} 102 | /> 103 | <% end %> 104 | <% end %> 105 | <%= if Enum.any?(@options) && !@hide_dropdown do %> 106 | 148 | <% end %> 149 |
150 | -------------------------------------------------------------------------------- /lib/mix/tasks/dump_style_table.ex: -------------------------------------------------------------------------------- 1 | # credo:disable-for-this-file 2 | defmodule Mix.Tasks.DumpStyleTable do 3 | @shortdoc "dump the table with the default styles used in the docs" 4 | 5 | use Mix.Task 6 | 7 | @impl true 8 | def run(_) do 9 | LiveSelect.Component.styles() 10 | |> Enum.map(fn {style, styles} -> 11 | Enum.map(styles, fn {el, class} -> {el, style, class} end) 12 | end) 13 | |> List.flatten() 14 | |> Enum.group_by(&elem(&1, 0)) 15 | |> Enum.map(fn {el, list} -> {el, Enum.map(list, &Tuple.delete_at(&1, 0)) |> Enum.sort()} end) 16 | |> Enum.sort_by(&elem(&1, 0)) 17 | |> Enum.with_index() 18 | |> Enum.map(fn {{el, styles}, idx} -> 19 | header = 20 | if idx == 0 do 21 | "| Element | " <> 22 | (Keyword.keys(styles) |> Enum.map_join(" | ", &"Default #{&1} classes")) <> 23 | " | Class override option | Class extend option |\n" <> 24 | "|----|" <> 25 | (Keyword.keys(styles) |> Enum.map_join("|", fn _ -> "----" end)) <> "|----|----|\n" 26 | else 27 | "" 28 | end 29 | 30 | header <> 31 | "| *#{el}* | " <> 32 | (Keyword.values(styles) |> Enum.map_join(" | ", &format_styles(&1))) <> 33 | " | #{class_override_option(el)} | #{class_extend_option(el)} |\n" 34 | end) 35 | |> IO.write() 36 | end 37 | 38 | defp class_override_option(el) do 39 | option_name = to_string(el) <> "_class" 40 | if option_name in class_options(), do: option_name, else: "" 41 | end 42 | 43 | defp class_extend_option(el) do 44 | option_name = to_string(el) <> "_extra_class" 45 | if option_name in class_options(), do: option_name, else: "" 46 | end 47 | 48 | defp class_options(), 49 | do: Enum.map(LiveSelect.Component.default_opts() |> Keyword.keys(), &to_string/1) 50 | 51 | defp format_styles(nil), do: "" 52 | 53 | defp format_styles(styles) do 54 | styles 55 | |> Enum.sort() 56 | |> Enum.join(" ") 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/support/application.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveSelect.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | # Start the PubSub system 12 | {Phoenix.PubSub, name: LiveSelect.PubSub}, 13 | # Start the Endpoint (http/https), 14 | LiveSelect.CityFinder, 15 | LiveSelectWeb.Endpoint 16 | ] 17 | 18 | # See https://hexdocs.pm/elixir/Supervisor.html 19 | # for other strategies and supported options 20 | opts = [strategy: :one_for_one, name: LiveSelect.Supervisor] 21 | Supervisor.start_link(children, opts) 22 | end 23 | 24 | # Tell Phoenix to update the endpoint configuration 25 | # whenever the application is updated. 26 | @impl true 27 | def config_change(changed, _new, removed) do 28 | LiveSelectWeb.Endpoint.config_change(changed, removed) 29 | :ok 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/support/change_event_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveSelect.ChangeEventHandler do 2 | @moduledoc false 3 | 4 | alias LiveSelect.CityFinder 5 | 6 | defmodule Behaviour do 7 | @moduledoc false 8 | 9 | @callback handle(params :: %{String.t() => String.t()}, opts :: Keyword.t()) :: any() 10 | end 11 | 12 | @behaviour Behaviour 13 | 14 | @impl true 15 | def handle(%{"text" => text} = params, opts) do 16 | result = GenServer.call(CityFinder, {:find, text}) 17 | 18 | Process.send_after( 19 | self(), 20 | {:update_live_select, params, result}, 21 | opts[:delay] 22 | ) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/support/city.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveSelect.City do 2 | @moduledoc false 3 | 4 | use Ecto.Schema 5 | 6 | import Ecto.Changeset 7 | 8 | @primary_key false 9 | embedded_schema do 10 | field(:name) 11 | field(:pos, {:array, :float}) 12 | end 13 | 14 | def changeset(%__MODULE__{} = schema \\ %__MODULE__{}, params) do 15 | cast(schema, params, [:name, :pos]) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/support/city_finder.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveSelect.CityFinder do 2 | @moduledoc false 3 | 4 | use GenServer 5 | 6 | alias LiveSelect.City 7 | 8 | def start_link(_) do 9 | GenServer.start_link(__MODULE__, [], name: __MODULE__) 10 | end 11 | 12 | @impl true 13 | def init(_) do 14 | cities = 15 | Application.app_dir(:live_select, Path.join("priv", "cities.json")) 16 | |> File.read!() 17 | |> Phoenix.json_library().decode!() 18 | 19 | {:ok, cities} 20 | end 21 | 22 | @impl true 23 | def handle_call({:find, ""}, _from, cities), do: {:reply, [], cities} 24 | 25 | @impl true 26 | def handle_call({:find, term}, _from, cities) do 27 | result = 28 | cities 29 | |> Enum.filter(fn %{"name" => name} -> 30 | String.contains?(String.downcase(name), String.downcase(term)) 31 | end) 32 | |> Enum.map(fn %{"name" => name, "loc" => %{"coordinates" => coord}} -> 33 | %{name: name, pos: coord} 34 | end) 35 | |> Enum.map(&struct!(City, &1)) 36 | 37 | {:reply, result, cities} 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/support/live_select_web.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveSelectWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use LiveSelectWeb, :controller 9 | use LiveSelectWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | def controller do 23 | quote do 24 | use Phoenix.Controller, 25 | formats: [:html, :json], 26 | layouts: [html: LiveSelectWeb.Layouts] 27 | 28 | import Plug.Conn 29 | 30 | unquote(verified_routes()) 31 | end 32 | end 33 | 34 | def live_view do 35 | quote do 36 | use Phoenix.LiveView, 37 | layout: {LiveSelectWeb.Layouts, :app} 38 | 39 | unquote(html_helpers()) 40 | end 41 | end 42 | 43 | def live_component do 44 | quote do 45 | use Phoenix.LiveComponent 46 | 47 | unquote(html_helpers()) 48 | end 49 | end 50 | 51 | def html do 52 | quote do 53 | use Phoenix.Component 54 | 55 | # Import convenience functions from controllers 56 | import Phoenix.Controller, 57 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] 58 | 59 | # Include general helpers for rendering HTML 60 | unquote(html_helpers()) 61 | end 62 | end 63 | 64 | def router do 65 | quote do 66 | use Phoenix.Router 67 | 68 | import Plug.Conn 69 | import Phoenix.Controller 70 | import Phoenix.LiveView.Router 71 | end 72 | end 73 | 74 | def channel do 75 | quote do 76 | use Phoenix.Channel 77 | end 78 | end 79 | 80 | defp html_helpers do 81 | quote do 82 | # HTML escaping functionality 83 | import Phoenix.HTML 84 | import Phoenix.HTML.Form 85 | import LiveSelectWeb.ErrorHelpers 86 | 87 | # Shortcut for generating JS commands 88 | alias Phoenix.LiveView.JS 89 | 90 | # Routes generation with the ~p sigil 91 | unquote(verified_routes()) 92 | end 93 | end 94 | 95 | def verified_routes do 96 | quote do 97 | use Phoenix.VerifiedRoutes, 98 | endpoint: LiveSelectWeb.Endpoint, 99 | router: LiveSelectWeb.Router, 100 | statics: LiveSelectWeb.static_paths() 101 | end 102 | end 103 | 104 | @doc """ 105 | When used, dispatch to the appropriate controller/view/etc. 106 | """ 107 | defmacro __using__(which) when is_atom(which) do 108 | apply(__MODULE__, which, []) 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/support/live_select_web/components/layouts.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveSelectWeb.Layouts do 2 | @moduledoc false 3 | 4 | use LiveSelectWeb, :html 5 | 6 | embed_templates "layouts/*" 7 | end 8 | -------------------------------------------------------------------------------- /lib/support/live_select_web/components/layouts/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 | <%= if Phoenix.Flash.get(@flash, :info) do %> 3 | 6 | <% end %> 7 | 8 | <%= if Phoenix.Flash.get(@flash, :error) do %> 9 | 12 | <% end %> 13 | 14 | {@inner_content} 15 |
16 | -------------------------------------------------------------------------------- /lib/support/live_select_web/components/layouts/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <.live_title suffix=" · Showcase"> 9 | {assigns[:page_title] || "LiveSelect"} 10 | 11 | 12 | 14 | 15 | 16 | {@inner_content} 17 | 18 | 19 | -------------------------------------------------------------------------------- /lib/support/live_select_web/controllers/error_html.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveSelectWeb.ErrorHTML do 2 | use LiveSelectWeb, :html 3 | 4 | # If you want to customize your error pages, 5 | # uncomment the embed_templates/1 call below 6 | # and add pages to the error directory: 7 | # 8 | # * lib/phoenix_playground_web/controllers/error_html/404.html.heex 9 | # * lib/phoenix_playground_web/controllers/error_html/500.html.heex 10 | # 11 | # embed_templates "error_html/*" 12 | 13 | # The default is to render a plain text page based on 14 | # the template name. For example, "404.html" becomes 15 | # "Not Found". 16 | def render(template, _assigns) do 17 | Phoenix.Controller.status_message_from_template(template) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/support/live_select_web/controllers/error_json.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveSelectWeb.ErrorJSON do 2 | # If you want to customize a particular status code, 3 | # you may add your own clauses, such as: 4 | # 5 | # def render("500.json", _assigns) do 6 | # %{errors: %{detail: "Internal Server Error"}} 7 | # end 8 | 9 | # By default, Phoenix returns the status message from 10 | # the template name. For example, "404.json" becomes 11 | # "Not Found". 12 | def render(template, _assigns) do 13 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/support/live_select_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveSelectWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :live_select 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_live_select_key", 10 | signing_salt: "ZnGUD174" 11 | ] 12 | 13 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 14 | 15 | # Serve at "/" the static files from "priv/static" directory. 16 | # 17 | # You should set gzip to true if you are running phx.digest 18 | # when deploying your static files in production. 19 | plug Plug.Static, 20 | at: "/", 21 | from: :live_select, 22 | gzip: false, 23 | only: LiveSelectWeb.static_paths() 24 | 25 | # Code reloading can be explicitly enabled under the 26 | # :code_reloader configuration of your endpoint. 27 | if code_reloading? do 28 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 29 | plug Phoenix.LiveReloader 30 | plug Phoenix.CodeReloader 31 | end 32 | 33 | plug Plug.RequestId 34 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 35 | 36 | plug Plug.Parsers, 37 | parsers: [:urlencoded, :multipart, :json], 38 | pass: ["*/*"], 39 | json_decoder: Phoenix.json_library() 40 | 41 | plug Plug.MethodOverride 42 | plug Plug.Head 43 | plug Plug.Session, @session_options 44 | plug LiveSelectWeb.Router 45 | end 46 | -------------------------------------------------------------------------------- /lib/support/live_select_web/live/live_component_form.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveSelectWeb.LiveComponentForm do 2 | @moduledoc false 3 | 4 | use LiveSelectWeb, :live_component 5 | alias LiveSelect.CityFinder 6 | 7 | import LiveSelect 8 | use PhoenixHTMLHelpers 9 | 10 | @impl true 11 | def mount(socket) do 12 | socket = assign(socket, :form, to_form(%{}, as: "my_form")) 13 | 14 | {:ok, socket} 15 | end 16 | 17 | @impl true 18 | def render(assigns) do 19 | ~H""" 20 |
21 | <.form for={@form} phx-submit="submit" phx-change="change" phx-target={@myself}> 22 | <.live_select field={@form[:city_search]} mode={:tags} phx-target={@myself}> 23 | <:option :let={option}> 24 | with custom slot: {option.label} 25 | 26 | <:tag :let={option}> 27 | with custom slot: {option.label} 28 | 29 | 30 | <.live_select field={@form[:city_search_custom_clear_single]} phx-target={@myself} allow_clear> 31 | <:clear_button> 32 | custom clear button 33 | 34 | 35 | <.live_select 36 | field={@form[:city_search_custom_clear_tags]} 37 | phx-target={@myself} 38 | allow_clear 39 | mode={:tags} 40 | > 41 | <:clear_button> 42 | custom clear button 43 | 44 | 45 | {submit("Submit", class: "btn btn-primary")} 46 | 47 |
48 | """ 49 | end 50 | 51 | @impl true 52 | def handle_event("live_select_change", %{"id" => live_select_id, "text" => text}, socket) do 53 | result = 54 | GenServer.call(CityFinder, {:find, text}) 55 | |> Enum.map(&value_mapper/1) 56 | 57 | send_update(LiveSelect.Component, id: live_select_id, options: result) 58 | 59 | {:noreply, socket} 60 | end 61 | 62 | @impl true 63 | def handle_event("submit", %{"my_form" => %{"city_search" => _live_select}}, socket) do 64 | {:noreply, socket} 65 | end 66 | 67 | @impl true 68 | def handle_event("change", %{"my_form" => %{"city_search" => _live_select}}, socket) do 69 | {:noreply, socket} 70 | end 71 | 72 | defp value_mapper(%{name: name} = value), do: %{label: name, value: Map.from_struct(value)} 73 | 74 | defp value_mapper(value), do: value 75 | end 76 | -------------------------------------------------------------------------------- /lib/support/live_select_web/live/live_component_test.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveSelectWeb.LiveComponentTest do 2 | @moduledoc false 3 | 4 | use LiveSelectWeb, :live_view 5 | 6 | alias LiveSelectWeb.LiveComponentForm 7 | 8 | @impl true 9 | def render(assigns) do 10 | ~H""" 11 | <.live_component module={LiveComponentForm} id="lc_form" /> 12 | """ 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/support/live_select_web/live/showcase_live.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveSelectWeb.ShowcaseLive do 2 | use LiveSelectWeb, :live_view 3 | 4 | import LiveSelect 5 | alias LiveSelect.{Component, City} 6 | 7 | use PhoenixHTMLHelpers 8 | 9 | defmodule CitySearchMany do 10 | use Ecto.Schema 11 | 12 | import Ecto.Changeset 13 | 14 | embedded_schema do 15 | embeds_many(:city_search, City, on_replace: :delete) 16 | end 17 | 18 | def changeset(schema \\ %__MODULE__{}, params) do 19 | cast(schema, params, []) 20 | |> cast_embed(:city_search) 21 | end 22 | end 23 | 24 | defmodule CitySearchSingle do 25 | use Ecto.Schema 26 | 27 | import Ecto.Changeset 28 | 29 | embedded_schema do 30 | embeds_one(:city_search, City, on_replace: :update) 31 | end 32 | 33 | def changeset(schema \\ %__MODULE__{}, params) do 34 | cast(schema, params, []) 35 | |> cast_embed(:city_search) 36 | end 37 | end 38 | 39 | defmodule Settings do 40 | use Ecto.Schema 41 | 42 | import Ecto.Changeset 43 | 44 | @class_options [ 45 | :active_option_class, 46 | :available_option_class, 47 | :unavailable_option_class, 48 | :clear_button_class, 49 | :clear_button_extra_class, 50 | :container_class, 51 | :container_extra_class, 52 | :dropdown_class, 53 | :dropdown_extra_class, 54 | :option_class, 55 | :option_extra_class, 56 | :selected_option_class, 57 | :tag_class, 58 | :tag_extra_class, 59 | :tags_container_class, 60 | :tags_container_extra_class, 61 | :text_input_class, 62 | :text_input_extra_class, 63 | :text_input_selected_class 64 | ] 65 | 66 | @primary_key false 67 | embedded_schema do 68 | field(:allow_clear, :boolean) 69 | field(:debounce, :integer, default: Component.default_opts()[:debounce]) 70 | field(:disabled, :boolean) 71 | field(:options_styled_as_checkboxes, :boolean) 72 | field(:max_selectable, :integer, default: Component.default_opts()[:max_selectable]) 73 | field(:user_defined_options, :boolean) 74 | 75 | field(:mode, Ecto.Enum, 76 | values: [:single, :tags, :quick_tags], 77 | default: Component.default_opts()[:mode] 78 | ) 79 | 80 | field(:new, :boolean, default: true) 81 | field(:placeholder, :string, default: "Search for a city") 82 | field(:search_delay, :integer, default: 10) 83 | field(:style, Ecto.Enum, values: [:daisyui, :tailwind, :none], default: :tailwind) 84 | field(:update_min_len, :integer, default: 3) 85 | field(:options, {:array, :string}, default: []) 86 | field(:selection, {:array, :string}, default: []) 87 | field(:"phx-blur", :string) 88 | field(:"phx-focus", :string) 89 | 90 | for class <- @class_options do 91 | field(class, :string) 92 | end 93 | end 94 | 95 | def changeset(source \\ %__MODULE__{}, params) do 96 | source 97 | |> cast( 98 | params, 99 | [ 100 | :allow_clear, 101 | :debounce, 102 | :disabled, 103 | :options_styled_as_checkboxes, 104 | :max_selectable, 105 | :user_defined_options, 106 | :mode, 107 | :options, 108 | :selection, 109 | :placeholder, 110 | :search_delay, 111 | :style, 112 | :update_min_len, 113 | :"phx-focus", 114 | :"phx-blur" 115 | ] ++ @class_options 116 | ) 117 | |> validate_required([:search_delay]) 118 | |> validate_number(:debounce, greater_than_or_equal_to: 0) 119 | |> validate_number(:search_delay, greater_than_or_equal_to: 0) 120 | |> validate_number(:update_min_len, greater_than_or_equal_to: 0) 121 | |> maybe_apply_initial_styles() 122 | |> validate_styles() 123 | |> put_change(:new, false) 124 | end 125 | 126 | def live_select_opts(%__MODULE__{} = settings, remove_defaults \\ false) do 127 | default_opts = Component.default_opts() 128 | 129 | settings 130 | |> Map.drop([:search_delay, :new, :selection, :options_styled_as_checkboxes]) 131 | |> Map.from_struct() 132 | |> then( 133 | &if is_nil(&1.style) do 134 | Map.delete(&1, :style) 135 | else 136 | &1 137 | end 138 | ) 139 | |> Map.reject(fn {option, value} -> 140 | (remove_defaults && value == Keyword.get(default_opts, option)) || 141 | (settings.mode == :single && option == :max_selectable) || 142 | (settings.mode != :single && option == :allow_clear) 143 | end) 144 | |> Keyword.new() 145 | end 146 | 147 | def has_style_errors?(%Ecto.Changeset{errors: errors}) do 148 | errors 149 | |> Keyword.take(@class_options) 150 | |> Enum.any?() 151 | end 152 | 153 | def has_style_options?(changeset) do 154 | Enum.any?(@class_options, &Ecto.Changeset.get_field(changeset, &1)) 155 | end 156 | 157 | def class_options(), do: @class_options 158 | 159 | defp validate_styles(changeset) do 160 | for {class, extra_class} <- [ 161 | {:container_class, :container_extra_class}, 162 | {:dropdown_class, :dropdown_extra_class}, 163 | {:text_input_class, :text_input_extra_class}, 164 | {:option_class, :option_extra_class}, 165 | {:tags_container_class, :tags_container_extra_class}, 166 | {:tag_class, :tag_extra_class} 167 | ], 168 | reduce: changeset do 169 | changeset -> 170 | cond do 171 | get_field(changeset, :style) == :none && get_field(changeset, extra_class) -> 172 | add_error(changeset, extra_class, "Can't specify this when style is none") 173 | 174 | get_field(changeset, :style) != :none && get_field(changeset, class) && 175 | get_field(changeset, extra_class) -> 176 | errmsgs = "You can only specify one of these" 177 | 178 | changeset 179 | |> add_error(class, errmsgs) 180 | |> add_error(extra_class, errmsgs) 181 | 182 | true -> 183 | changeset 184 | end 185 | end 186 | end 187 | 188 | defp maybe_apply_initial_styles(changeset) do 189 | new_settings = get_field(changeset, :new) 190 | 191 | if new_settings do 192 | initial_classes = 193 | Application.get_env(:live_select, :initial_classes, []) 194 | |> Keyword.get(Component.default_opts()[:style], []) 195 | 196 | initial_classes 197 | |> Enum.reduce(changeset, fn {class, initial_value}, changeset -> 198 | put_change(changeset, class, initial_value) 199 | end) 200 | else 201 | changeset 202 | end 203 | end 204 | end 205 | 206 | @max_events 3 207 | 208 | defmodule Render do 209 | @moduledoc false 210 | 211 | use Phoenix.Component 212 | 213 | def event(assigns) do 214 | cond do 215 | assigns[:event] -> 216 | ~H""" 217 |

218 | handle_event( <%= inspect(@event) %>, <%= inspect( 219 | @params 220 | ) %>, 221 | socket 222 | ) 223 |

224 | """ 225 | 226 | assigns[:msg] -> 227 | ~H""" 228 |

229 | def handle_info( <%= inspect(@msg) %>, 230 | socket 231 | ) 232 |

233 | """ 234 | end 235 | end 236 | 237 | @spec live_select(nil | maybe_improper_list() | map()) :: Phoenix.LiveView.Rendered.t() 238 | def live_select(assigns) do 239 | opts = 240 | assigns[:opts] 241 | |> Enum.reject(fn {_key, value} -> is_nil(value) end) 242 | 243 | format_value = fn 244 | value when is_binary(value) -> inspect(value) 245 | value -> "{#{inspect(value)}}" 246 | end 247 | 248 | assigns = assign(assigns, opts: opts, format_value: format_value) 249 | 250 | ~H""" 251 | <%= if @options_styled_as_checkboxes do %> 252 |
253 | <.live_select 254 |
   field={my_form[:city_search]} 255 | <%= for {key, value} <- @opts, !is_nil(value) do %> 256 | <%= if value == true do %> 257 |
   {key} 258 | <% else %> 259 |
   <%= key %>=<%= @format_value.(value) %> 260 | <% end %> 261 | <% end %> 262 |
> 263 | 264 |
   265 | <:option :let={%{label: label, value: value, selected: selected}} 266 | 267 | > 268 | 269 |
<%= indent(2) %> <div class="flex justify-content items-center"> 270 |
<%= indent(3) %> <input 271 |
<%= indent(4) %> class="rounded w-4 h-4 mr-3 border border-border" 272 |
<%= indent(4) %> type="checkbox" 273 |
<%= indent(4) %> checked={selected} 274 |
<%= indent(3) %> /> 275 |
<%= indent(4) %> <span class="text-sm"><%= label %> 276 |
<%= indent(2) %> </div> 277 |
<%= indent(1) %> </:option> 278 |
<.live_select/> 279 |
280 | <% else %> 281 |
282 | <.live_select 283 |
   field={my_form[:city_search]} 284 | <%= for {key, value} <- @opts, !is_nil(value) do %> 285 | <%= if value == true do %> 286 |
   {key} 287 | <% else %> 288 |
   <%= key %>=<%= @format_value.(value) %> 289 | <% end %> 290 | <% end %> 291 | /> 292 |
293 | <% end %> 294 | """ 295 | end 296 | 297 | defp indent(amount) do 298 | raw(for _ <- 1..amount, do: "   ") 299 | end 300 | end 301 | 302 | @impl true 303 | def mount(_params, _session, socket) do 304 | socket = 305 | assign(socket, 306 | live_select_form: to_form(CitySearchSingle.changeset(%{}), as: "my_form"), 307 | schema_module: CitySearchSingle, 308 | events: [], 309 | next_event_id: 0, 310 | locations: nil, 311 | submitted: false, 312 | save_classes_pid: nil, 313 | show_styles: false, 314 | class_options: Settings.class_options(), 315 | style_filter: "", 316 | dark_mode: false 317 | ) 318 | 319 | {:ok, socket} 320 | end 321 | 322 | @impl true 323 | def handle_params(params, _url, socket) do 324 | socket = 325 | if params["reset"] do 326 | socket 327 | |> assign(:events, []) 328 | else 329 | socket 330 | end 331 | 332 | changeset = 333 | Settings.changeset(params) 334 | |> then( 335 | &if params["placeholder"] == "" do 336 | Ecto.Changeset.put_change(&1, :placeholder, nil) 337 | else 338 | &1 339 | end 340 | ) 341 | 342 | case Ecto.Changeset.apply_action(changeset, :create) do 343 | {:ok, settings} -> 344 | socket.assigns 345 | 346 | socket = 347 | socket 348 | |> assign(:settings_form, Settings.changeset(settings, %{}) |> to_form) 349 | |> update(:schema_module, fn _, %{settings_form: settings_form} -> 350 | if settings_form[:mode].value == :single, do: CitySearchSingle, else: CitySearchMany 351 | end) 352 | 353 | {:noreply, socket} 354 | 355 | {:error, changeset} -> 356 | socket = 357 | socket 358 | |> assign(:settings_form, to_form(changeset)) 359 | |> assign( 360 | :show_styles, 361 | socket.assigns.show_styles || Settings.has_style_errors?(changeset) 362 | ) 363 | 364 | {:noreply, socket} 365 | end 366 | end 367 | 368 | @impl true 369 | def handle_event( 370 | "update-settings", 371 | %{"settings" => params, "_target" => target}, 372 | socket 373 | ) do 374 | params = 375 | params 376 | |> Enum.reject(fn {k, v} -> v == "" && k != "placeholder" end) 377 | |> Map.new() 378 | 379 | socket = 380 | if target == ~w(settings mode) do 381 | assign( 382 | socket, 383 | :schema_module, 384 | if(params["mode"] == "single", do: CitySearchSingle, else: CitySearchMany) 385 | ) 386 | |> update(:live_select_form, fn _, %{schema_module: schema_module} -> 387 | to_form(schema_module.changeset(%{}), as: "my_form") 388 | end) 389 | else 390 | socket 391 | end 392 | 393 | socket = push_patch(socket, to: ~p(/?#{params})) 394 | 395 | {:noreply, socket} 396 | end 397 | 398 | def handle_event( 399 | "toggle-styles", 400 | %{"settings" => %{"show_styles" => show_styles}}, 401 | socket 402 | ) do 403 | {:noreply, assign(socket, :show_styles, show_styles == "true")} 404 | end 405 | 406 | def handle_event("dark-mode", value, socket) do 407 | {:noreply, assign(socket, :dark_mode, value)} 408 | end 409 | 410 | def handle_event("filter-styles", %{"settings" => %{"style_filter" => filter}}, socket) do 411 | {:noreply, assign(socket, style_filter: filter)} 412 | end 413 | 414 | def handle_event("clear-style-filter", _params, socket) do 415 | {:noreply, assign(socket, style_filter: "")} 416 | end 417 | 418 | def handle_event("clear-selection", _params, socket) do 419 | send_update(Component, 420 | id: "my_form_city_search_live_select_component", 421 | value: nil 422 | ) 423 | 424 | {:noreply, socket} 425 | end 426 | 427 | @impl true 428 | def handle_event(event, params, socket) do 429 | socket = 430 | case event do 431 | "live_select_change" -> 432 | change_event_handler().handle(params, 433 | delay: socket.assigns.settings_form.data.search_delay 434 | ) 435 | 436 | socket 437 | 438 | event when event in ~w(change submit) -> 439 | params = 440 | update_in(params, ~w(my_form city_search), fn value -> 441 | case value do 442 | nil -> [] 443 | "" -> nil 444 | value when is_list(value) -> Enum.map(value, &safe_decode/1) 445 | value -> safe_decode(value) 446 | end 447 | end) 448 | 449 | changeset = socket.assigns.schema_module.changeset(params["my_form"]) 450 | 451 | socket = 452 | assign( 453 | socket, 454 | :live_select_form, 455 | to_form(changeset, as: "my_form") 456 | ) 457 | 458 | if event == "submit" do 459 | selection = 460 | Ecto.Changeset.apply_changes(changeset).city_search 461 | |> then(fn 462 | city_search when is_list(city_search) -> 463 | Enum.map(city_search, &Map.from_struct(&1)) 464 | 465 | nil -> 466 | nil 467 | 468 | city_search -> 469 | Map.from_struct(city_search) 470 | end) 471 | |> then(fn 472 | [] -> nil 473 | selection -> selection 474 | end) 475 | 476 | assign(socket, 477 | cities: selection && Phoenix.json_library().encode!(selection), 478 | submitted: true 479 | ) 480 | else 481 | socket 482 | end 483 | 484 | _event -> 485 | socket 486 | end 487 | 488 | socket = 489 | socket 490 | |> update(:next_event_id, &(&1 + 1)) 491 | |> assign( 492 | events: 493 | [ 494 | %{params: params, event: event, id: socket.assigns.next_event_id} 495 | | socket.assigns.events 496 | ] 497 | |> Enum.take(@max_events) 498 | ) 499 | 500 | {:noreply, socket} 501 | end 502 | 503 | defp safe_decode(value) do 504 | case Phoenix.json_library().decode(value) do 505 | {:ok, decoded} -> decoded 506 | {:error, _} -> %{name: value, pos: []} 507 | end 508 | end 509 | 510 | def handle_info({:update_live_select, %{"id" => id}, options}, socket) do 511 | options = 512 | options 513 | |> Enum.sort() 514 | |> Enum.map(&value_mapper/1) 515 | 516 | send_update(Component, id: id, options: options) 517 | 518 | {:noreply, socket} 519 | end 520 | 521 | @impl true 522 | def handle_info(message, socket) do 523 | socket = 524 | socket 525 | |> update(:next_event_id, &(&1 + 1)) 526 | |> assign( 527 | events: 528 | [%{msg: message, id: socket.assigns.next_event_id} | socket.assigns.events] 529 | |> Enum.take(@max_events) 530 | ) 531 | 532 | {:noreply, socket} 533 | end 534 | 535 | def quick_tags?(mode) do 536 | mode == :quick_tags 537 | end 538 | 539 | defp value_mapper(%City{name: name} = value), do: %{label: name, value: Map.from_struct(value)} 540 | 541 | defp value_mapper(value), do: value 542 | 543 | defp live_select_assigns(changeset) do 544 | Settings.live_select_opts(changeset.data) 545 | |> Keyword.update(:disabled, !changeset.valid?, fn 546 | true -> true 547 | _ -> !changeset.valid? 548 | end) 549 | end 550 | 551 | defp default_value_descr(field) do 552 | if default = Component.default_opts()[field] do 553 | "default: #{default}" 554 | else 555 | "" 556 | end 557 | end 558 | 559 | defp default_class(style, class) do 560 | case Component.default_class(style, class) do 561 | nil -> nil 562 | list -> Enum.join(list, " ") 563 | end 564 | end 565 | 566 | defp change_event_handler() do 567 | Application.get_env(:live_select, :change_event_handler) || 568 | raise "you need to specify a :change_event_handler in your :live_select config" 569 | end 570 | 571 | defp valid_class(changeset, class) do 572 | changeset.errors[class] || 573 | Ecto.Changeset.get_field(changeset, :style) != :none || 574 | !String.contains?(to_string(class), "extra") 575 | end 576 | 577 | defp copy_to_clipboard_icon(assigns) do 578 | ~H""" 579 | 587 | 592 | 593 | """ 594 | end 595 | 596 | defp x_icon(assigns) do 597 | ~H""" 598 | 606 | 607 | 608 | """ 609 | end 610 | end 611 | -------------------------------------------------------------------------------- /lib/support/live_select_web/live/showcase_live.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 | 10 |
11 |
12 | ❌ Your settings are invalid, check the form for errors 13 |
14 |
15 |
16 |
17 |
18 | 29 |
30 |
31 | <.link patch={~p(/?reset=true)} class="btn btn-warning btn-sm mt-1"> 32 | Reset 33 | 34 |
35 |
36 |
37 |

Change settings here:

38 |
39 | <.form for={@settings_form} phx-change="update-settings" id="settings-form"> 40 |
41 |
42 | {label(@settings_form, :mode, "Mode:", class: "label label-text font-semibold")} 43 | {select(@settings_form, :mode, [:single, :tags, :quick_tags], 44 | class: "select select-sm select-bordered text-xs" 45 | )} 46 |
47 |
48 | {label(@settings_form, :max_selectable, "Max selectable:", 49 | class: "label label-text font-semibold" 50 | )} 51 | {number_input(@settings_form, :max_selectable, 52 | min: 0, 53 | class: "input input-sm input-bordered", 54 | disabled: to_string(@settings_form[:mode].value) == "single" 55 | )} 56 | {error_tag(@settings_form, :max_selectable)} 57 |
58 |
59 | 63 | 70 | <%= label class: "label cursor-pointer" do %> 71 | Disabled:  72 | {checkbox(@settings_form, :disabled, class: "toggle")} 73 | <% end %> 74 | <%= label class: "label cursor-pointer" do %> 75 | Options styled as checkboxes:  76 | <%= checkbox(@settings_form, :options_styled_as_checkboxes, class: "toggle") %> 77 | <% end %> 78 |
79 |
80 | {label(@settings_form, :search_delay, "Search delay in ms:", 81 | class: "label label-text font-semibold" 82 | )} 83 | {number_input(@settings_form, :search_delay, 84 | min: 0, 85 | class: "input input-sm input-bordered", 86 | spellcheck: "false" 87 | )} 88 | {error_tag(@settings_form, :search_delay)} 89 |
90 |
91 | {label(@settings_form, :placeholder, "placeholder:", 92 | class: "label label-text font-semibold" 93 | )} 94 | {text_input(@settings_form, :placeholder, 95 | class: "input input-sm input-bordered", 96 | spellcheck: "false" 97 | )} 98 | {label(@settings_form, :placeholder, default_value_descr(:placeholder), 99 | class: "label label-text" 100 | )} 101 | {error_tag(@settings_form, :placeholder)} 102 |
103 |
104 | {label(@settings_form, :update_min_len, "update_min_len:", 105 | class: "label label-text font-semibold" 106 | )} 107 | {number_input(@settings_form, :update_min_len, 108 | min: 0, 109 | class: "input input-sm input-bordered", 110 | spellcheck: "false" 111 | )} 112 | 115 | {error_tag(@settings_form, :update_min_len)} 116 |
117 |
118 | {label(@settings_form, :debounce, "debounce:", 119 | class: "label label-text font-semibold" 120 | )} 121 | {number_input(@settings_form, :debounce, 122 | min: 0, 123 | class: "input input-sm input-bordered", 124 | spellcheck: "false" 125 | )} 126 | 129 | {error_tag(@settings_form, :debounce)} 130 |
131 | 132 |
133 | {label(@settings_form, :"phx-focus", "focus event:", 134 | class: "label label-text font-semibold" 135 | )} 136 | {text_input(@settings_form, :"phx-focus", 137 | class: "input input-sm input-bordered", 138 | spellcheck: "false" 139 | )} 140 | 143 | {error_tag(@settings_form, :"phx-focus")} 144 |
145 | 146 |
147 | {label(@settings_form, :"phx-blur", "blur event:", 148 | class: "label label-text font-semibold" 149 | )} 150 | {text_input(@settings_form, :"phx-blur", 151 | class: "input input-sm input-bordered", 152 | spellcheck: "false" 153 | )} 154 | 157 | {error_tag(@settings_form, :"phx-blur")} 158 |
159 | 160 |
161 | <%= label class: "label cursor-pointer" do %> 162 | style:  163 | {select(@settings_form, :style, [:tailwind, :daisyui, :none], 164 | class: "select select-sm select-bordered text-xs" 165 | )} 166 | <% end %> 167 |
168 | 169 |
176 | {checkbox(@settings_form, :show_styles, 177 | phx_change: "toggle-styles", 178 | class: "w-full", 179 | value: @show_styles 180 | )} 181 |
182 | {if @show_styles, do: "Hide styling options", else: "Show styling options"}   183 | 189 |
190 |
191 |

192 | Set or override styling options. The defaults are set by the selected style. 193 | Classes in the defaults can be selectively 194 | excluded with the “!class-name” notation 195 |

196 |
197 | 198 |
199 | {text_input(@settings_form, :style_filter, 200 | value: @style_filter, 201 | class: "input input-bordered input-sm flex-1", 202 | phx_change: "filter-styles" 203 | )} 204 | 211 |
212 |
213 | 222 | <%= for class <- @class_options do %> 223 | <%= if valid_class(@settings_form.source, class) do %> 224 |
225 | {label(@settings_form, class, "#{class}:", 226 | class: 227 | "label label-text font-semibold #{if Ecto.Changeset.get_field(@settings_form.source, class), do: "text-info"}" 228 | )} 229 | {text_input(@settings_form, class, 230 | class: "input input-sm input-bordered", 231 | spellcheck: "false" 232 | )} 233 | <%= if default_class(Ecto.Changeset.get_field(@settings_form.source, :style), class) do %> 234 | 240 | <% end %> 241 | {error_tag(@settings_form, class)} 242 |
243 | <% end %> 244 | <% end %> 245 |
246 |
247 |
248 | 249 | 252 | 253 | 254 | 257 | 258 | 274 |
275 | 276 |
277 |
278 |

Try out the component here:

279 |
280 |
281 | <.form 282 | for={@live_select_form} 283 | phx-change="change" 284 | phx-submit="submit" 285 | id="live-select-form" 286 | > 287 |
288 |
289 | <.live_select 290 | field={@live_select_form[:city_search]} 291 | value_mapper={&value_mapper/1} 292 | {live_select_assigns(@settings_form.source)} 293 | > 294 | <:option :let={%{label: label, value: _value, selected: selected}}> 295 | <%= if @settings_form[:options_styled_as_checkboxes].value do %> 296 |
297 | 302 | <%= label %> 303 |
304 | <% else %> 305 | <%= label %> 306 | <% end %> 307 | 308 | 309 |
310 |
311 | {submit("Submit", 312 | class: "btn btn-primary btn-sm text-xs md:text-md", 313 | disabled: !@settings_form.source.valid? || @settings_form[:disabled].value 314 | )} 315 |
316 |
317 | !! This should not move when the dropdown opens !! 318 |
319 |
320 | 321 | ✅ You selected: {@cities} 322 | 323 | 324 | ❌ Nothing selected 325 | 326 |
327 |
328 | 329 |
330 | 331 |
335 |
340 | 347 |
348 | 352 |
353 |
354 |
355 |
356 | -------------------------------------------------------------------------------- /lib/support/live_select_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveSelectWeb.Router do 2 | use LiveSelectWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_live_flash 8 | plug :put_root_layout, {LiveSelectWeb.Layouts, :root} 9 | plug :protect_from_forgery 10 | plug :put_secure_browser_headers, %{"content-security-policy" => "default-src 'self'"} 11 | end 12 | 13 | pipeline :api do 14 | plug :accepts, ["json"] 15 | end 16 | 17 | scope "/", LiveSelectWeb do 18 | pipe_through :browser 19 | 20 | live "/", ShowcaseLive 21 | live "/live_component_test", LiveComponentTest 22 | end 23 | 24 | # Other scopes may use custom stacks. 25 | # scope "/api", LiveSelectWeb do 26 | # pipe_through :api 27 | # end 28 | end 29 | -------------------------------------------------------------------------------- /lib/support/live_select_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveSelectWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | import Phoenix.HTML.Form 7 | use PhoenixHTMLHelpers 8 | 9 | @doc """ 10 | Generates tag for inlined form input errors. 11 | """ 12 | def error_tag(form, field) do 13 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 14 | content_tag(:span, translate_error(error), 15 | class: "invalid-feedback", 16 | phx_feedback_for: input_name(form, field) 17 | ) 18 | end) 19 | end 20 | 21 | @doc """ 22 | Translates an error message. 23 | """ 24 | def translate_error({msg, opts}) do 25 | # Because the error messages we show in our forms and APIs 26 | # are defined inside Ecto, we need to translate them dynamically. 27 | Enum.reduce(opts, msg, fn {key, value}, acc -> 28 | String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) 29 | end) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveSelect.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: app(), 7 | version: "1.6.0", 8 | elixir: "~> 1.14", 9 | description: "Dynamic (multi)selection field for LiveView", 10 | elixirc_paths: elixirc_paths(Mix.env()), 11 | compilers: Mix.compilers(), 12 | start_permanent: Mix.env() == :prod, 13 | aliases: aliases(), 14 | deps: deps(), 15 | docs: docs(), 16 | package: package(), 17 | source_url: "https://github.com/maxmarcon/live_select", 18 | name: "LiveSelect" 19 | ] 20 | end 21 | 22 | # Configuration for the OTP application. 23 | # 24 | # Type `mix help compile.app` for more information. 25 | def application do 26 | if Application.get_env(app(), :start_application) do 27 | [ 28 | mod: {LiveSelect.Application, []}, 29 | extra_applications: [:logger, :runtime_tools] 30 | ] 31 | else 32 | [] 33 | end 34 | end 35 | 36 | defp app(), do: :live_select 37 | 38 | # Specifies which paths to compile per environment. 39 | defp elixirc_paths(:test), do: ["lib", "test/support"] 40 | 41 | defp elixirc_paths(:prod) do 42 | # so we do not compile web stuff when used as dependency 43 | ["lib/live_select", "lib/live_select.ex"] 44 | end 45 | 46 | defp elixirc_paths(_) do 47 | ["lib"] 48 | end 49 | 50 | # Specifies your project dependencies. 51 | # 52 | # Type `mix help deps` for examples and options. 53 | defp deps do 54 | [ 55 | {:phoenix_live_view, "~> 0.19 or ~> 1.0"}, 56 | {:phoenix_html, "~> 4.0"}, 57 | {:phoenix_html_helpers, "~> 1.0"}, 58 | {:jason, "~> 1.0", only: [:dev, :test, :demo]}, 59 | {:phoenix, ">= 1.6.0", optional: true}, 60 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 61 | {:phoenix_ecto, "~> 4.0", only: [:dev, :test, :demo]}, 62 | {:ecto, "~> 3.8"}, 63 | {:floki, ">= 0.30.0", only: :test}, 64 | {:esbuild, "~> 0.4", only: [:dev, :test, :demo]}, 65 | {:plug_cowboy, "~> 2.5", only: [:dev, :demo]}, 66 | {:faker, "~> 0.17", only: [:dev, :test]}, 67 | {:tailwind, "~> 0.2", only: [:dev, :test, :demo]}, 68 | {:ex_doc, "~> 0.27", only: :dev, runtime: false}, 69 | {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, 70 | {:mox, "~> 1.0", only: :test} 71 | ] 72 | end 73 | 74 | # Aliases are shortcuts or tasks specific to the current project. 75 | # For example, to install project dependencies and perform other setup tasks, run: 76 | # 77 | # $ mix setup 78 | # 79 | # See the documentation for `Mix` for more info on aliases. 80 | defp aliases do 81 | [ 82 | setup: ["deps.get", "cmd --cd assets yarn"], 83 | "assets.package": ["esbuild package"], 84 | "assets.deploy": [ 85 | "cmd --cd assets yarn", 86 | "tailwind default --minify", 87 | "esbuild default --minify", 88 | "phx.digest" 89 | ] 90 | ] 91 | end 92 | 93 | defp package() do 94 | [ 95 | licenses: ["Apache-2.0"], 96 | links: %{ 97 | "GitHub" => "https://github.com/maxmarcon/live_select" 98 | }, 99 | files: ~w(mix.exs lib/live_select** package.json priv/static/live_select.min.js) 100 | ] 101 | end 102 | 103 | defp docs() do 104 | [ 105 | main: "readme", 106 | extras: [ 107 | "README.md": [title: "Readme"], 108 | "styling.md": [title: "Styling Guide"], 109 | "cheatsheet.cheatmd": [title: "Cheatsheet"], 110 | "CHANGELOG.md": [] 111 | ], 112 | filter_modules: ~r/LiveSelect($|\.)/, 113 | groups_for_docs: [ 114 | Components: &(&1[:type] == :component) 115 | ] 116 | ] 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"}, 4 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 5 | "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, 6 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 7 | "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, 8 | "credo": {:hex, :credo, "1.7.10", "6e64fe59be8da5e30a1b96273b247b5cf1cc9e336b5fd66302a64b25749ad44d", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "71fbc9a6b8be21d993deca85bf151df023a3097b01e09a2809d460348561d8cd"}, 9 | "db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"}, 10 | "decimal": {:hex, :decimal, "2.2.0", "df3d06bb9517e302b1bd265c1e7f16cda51547ad9d99892049340841f3e15836", [:mix], [], "hexpm", "af8daf87384b51b7e611fb1a1f2c4d4876b65ef968fa8bd3adf44cff401c7f21"}, 11 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 12 | "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, 13 | "ecto_sql": {:hex, :ecto_sql, "3.9.0", "2bb21210a2a13317e098a420a8c1cc58b0c3421ab8e3acfa96417dab7817918c", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a8f3f720073b8b1ac4c978be25fa7960ed7fd44997420c304a4a2e200b596453"}, 14 | "esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"}, 15 | "ex_doc": {:hex, :ex_doc, "0.35.1", "de804c590d3df2d9d5b8aec77d758b00c814b356119b3d4455e4b8a8687aecaf", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2121c6402c8d44b05622677b761371a759143b958c6c19f6558ff64d0aed40df"}, 16 | "faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"}, 17 | "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, 18 | "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, 19 | "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, 20 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 21 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 22 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.0", "74bb8348c9b3a51d5c589bf5aebb0466a84b33274150e3b6ece1da45584afc82", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "49159b7d7d999e836bedaf09dcf35ca18b312230cf901b725a64f3f42e407983"}, 23 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 24 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 25 | "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, 26 | "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, 27 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 28 | "phoenix": {:hex, :phoenix, "1.7.17", "2fcdceecc6fb90bec26fab008f96abbd0fd93bc9956ec7985e5892cf545152ca", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "50e8ad537f3f7b0efb1509b2f75b5c918f697be6a45d48e49a30d3b7c0e464c9"}, 29 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, 30 | "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, 31 | "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, 32 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, 33 | "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.0", "3a10dfce8f87b2ad4dc65de0732fc2a11e670b2779a19e8d3281f4619a85bce4", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "254caef0028765965ca6bd104cc7d68dcc7d57cc42912bef92f6b03047251d99"}, 34 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 35 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 36 | "phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"}, 37 | "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, 38 | "plug_cowboy": {:hex, :plug_cowboy, "2.7.2", "fdadb973799ae691bf9ecad99125b16625b1c6039999da5fe544d99218e662e4", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "245d8a11ee2306094840c000e8816f0cbed69a23fc0ac2bcf8d7835ae019bb2f"}, 39 | "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, 40 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 41 | "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"}, 42 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 43 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, 44 | "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, 45 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 46 | "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "live_select", 3 | "version": "1.6.0", 4 | "description": "JS hooks for LiveSelect", 5 | "main": "priv/static/live_select.min.js", 6 | "repository": "git@github.com:maxmarcon/live_select.git", 7 | "author": "Max Marcon ", 8 | "license": "Apache License 2.0" 9 | } 10 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxmarcon/live_select/0c74104cd081390d7e29c8929b3e5355262eae35/priv/static/favicon.ico -------------------------------------------------------------------------------- /priv/static/images/daisyui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxmarcon/live_select/0c74104cd081390d7e29c8929b3e5355262eae35/priv/static/images/daisyui.png -------------------------------------------------------------------------------- /priv/static/images/demo_quick_tags.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxmarcon/live_select/0c74104cd081390d7e29c8929b3e5355262eae35/priv/static/images/demo_quick_tags.gif -------------------------------------------------------------------------------- /priv/static/images/demo_single.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxmarcon/live_select/0c74104cd081390d7e29c8929b3e5355262eae35/priv/static/images/demo_single.gif -------------------------------------------------------------------------------- /priv/static/images/demo_tags.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxmarcon/live_select/0c74104cd081390d7e29c8929b3e5355262eae35/priv/static/images/demo_tags.gif -------------------------------------------------------------------------------- /priv/static/images/github-mark-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxmarcon/live_select/0c74104cd081390d7e29c8929b3e5355262eae35/priv/static/images/github-mark-white.png -------------------------------------------------------------------------------- /priv/static/images/github-mark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxmarcon/live_select/0c74104cd081390d7e29c8929b3e5355262eae35/priv/static/images/github-mark.png -------------------------------------------------------------------------------- /priv/static/images/showcase.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxmarcon/live_select/0c74104cd081390d7e29c8929b3e5355262eae35/priv/static/images/showcase.gif -------------------------------------------------------------------------------- /priv/static/images/slots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxmarcon/live_select/0c74104cd081390d7e29c8929b3e5355262eae35/priv/static/images/slots.png -------------------------------------------------------------------------------- /priv/static/images/styled_elements.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxmarcon/live_select/0c74104cd081390d7e29c8929b3e5355262eae35/priv/static/images/styled_elements.png -------------------------------------------------------------------------------- /priv/static/images/styled_elements_tags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxmarcon/live_select/0c74104cd081390d7e29c8929b3e5355262eae35/priv/static/images/styled_elements_tags.png -------------------------------------------------------------------------------- /priv/static/images/tailwind.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxmarcon/live_select/0c74104cd081390d7e29c8929b3e5355262eae35/priv/static/images/tailwind.png -------------------------------------------------------------------------------- /priv/static/live_select.min.js: -------------------------------------------------------------------------------- 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)},maybeStyleClearButton(){const e=this.el.querySelector("button[phx-click=clear]");e&&(this.textInput().parentElement.style.position="relative",e.style.position="absolute",e.style.top="0px",e.style.bottom="0px",e.style.right="5px",e.style.display="block")},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.maybeStyleClearButton(),this.handleEvent("parent_event",({id:e,event:t,payload:i})=>{this.el.id===e&&this.pushEventToParent(t,i)}),this.handleEvent("select",({id:e,selection:t,mode:i,current_text:s,input_event:l,parent_event:n})=>{if(this.el.id===e){if(this.selection=t,i==="single"){const h=t.length>0?t[0].label:s;this.setInputValue(h)}else this.setInputValue(s);l&&this.inputEvent(t,i),n&&this.pushEventToParent(n,{id:e})}}),this.handleEvent("active",({id:e,idx:t})=>{if(this.el.id===e){const i=this.el.querySelector(`div[data-idx="${t}"]`);i&&i.scrollIntoView({block:"nearest"})}}),this.attachDomEventHandlers()},updated(){this.maybeStyleClearButton(),this.attachDomEventHandlers()},reconnected(){this.selection&&this.selection.length>0&&this.pushEventTo(this.el.id,"selection_recovery",this.selection)}}}; 2 | -------------------------------------------------------------------------------- /priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd -P -- "$(dirname -- "$0")" 3 | PHX_SERVER=true exec ./live_select start 4 | -------------------------------------------------------------------------------- /rel/overlays/bin/server.bat: -------------------------------------------------------------------------------- 1 | set PHX_SERVER=true 2 | call "%~dp0\live_select" start 3 | -------------------------------------------------------------------------------- /styling.md: -------------------------------------------------------------------------------- 1 | # Styling 2 | 3 | `LiveSelect` supports 3 styling modes: 4 | 5 | * `tailwind`: uses standard tailwind utility classes (the default) 6 | * `daisyui`: uses [daisyUI](https://daisyui.com/) classes. 7 | * `none`: no styling at all. 8 | 9 | The choice of style is controlled by the `style` option in [live_select/1](`LiveSelect.live_select/1`). 10 | `tailwind` and `daisyui` styles come with sensible defaults which can be extended or overridden via options. 11 | 12 | This is what each default style looks like: 13 | 14 | ### daisyui: 15 | 16 | daisyui example 17 | 18 | (the actual colors may differ depending on the selected [daisyui theme](https://daisyui.com/docs/themes/)) 19 | 20 | ### tailwind: 21 | 22 | tailwind example 23 | 24 | These defaults can be _selectively overridden or extended_ using the appropriate options 25 | to [live_select/1](`LiveSelect.live_select/1`). 26 | 27 | You can control the style of the following elements: 28 | 29 | 1. The outer **container** of the live_select component 30 | 2. The **text_input** field 31 | 3. The **text_input_selected** text field when an option has been selected 32 | 4. The **dropdown** that contains the selectable options 33 | 5. The single selectable **option**(s) 34 | 6. The currently **active_option** 35 | 7. The **clear_button** to clear the selection (only if `allow_clear` is set) 36 | 8. **selected_option**. This is an option in the dropdown that has already been selected. It's still visible, but can't be selected again 37 | 9. **available_option**. This is an option in the dropdown that has not been selected and is available for selection 38 | 10. **unavailable_option**. This is an option in the dropdown that has not been selected but is not available for selection. This happens when there is a specified maximum number of selectable elements and that number has been reached. 39 | 40 | Here's a visual representation of the elements: 41 | 42 | ![styled elements](https://raw.githubusercontent.com/maxmarcon/live_select/main/priv/static/images/styled_elements.png) 43 | 44 | In `tags` and `quick_tags` mode there are 3 additional stylable elements: 45 | 46 | 11. **tag** showing the selected options 47 | 12. **tags_container** that contains the tags 48 | 13. **clear_tag_button** button to remove the tags 49 | 50 | ![styled elements_tags](https://raw.githubusercontent.com/maxmarcon/live_select/main/priv/static/images/styled_elements_tags.png) 51 | 52 | For each of these elements there is an `{element}_class` and for some also an `{element}_extra_class` option, which can 53 | be used 54 | to override or extend the default CSS classes for the element, respectively. These options accept both strings and lists of strings. 55 | You can't use both options together: 56 | use `{element}_class` 57 | to completely override the default classes, or use `{element}_extra_class` to extend the default. 58 | 59 | The following table shows the default styles for each element and the options you can use to adjust its CSS classes. 60 | 61 | | Element | Default daisyui classes | Default tailwind classes | Class override option | Class extend option | 62 | |----|----|----|----|----| 63 | | *active_option* | active menu-active | bg-gray-600 text-white | active_option_class | | 64 | | *available_option* | cursor-pointer | cursor-pointer hover:bg-gray-400 rounded | available_option_class | | 65 | | *clear_button* | cursor-pointer hidden | cursor-pointer hidden | clear_button_class | clear_button_extra_class | 66 | | *clear_tag_button* | cursor-pointer | cursor-pointer | clear_tag_button_class | clear_tag_button_extra_class | 67 | | *container* | dropdown dropdown-open | h-full relative text-black | container_class | container_extra_class | 68 | | *dropdown* | bg-base-200 dropdown-content menu menu-compact p-1 rounded-box shadow w-full z-[1] | absolute bg-gray-100 inset-x-0 rounded-md shadow top-full z-50 | dropdown_class | dropdown_extra_class | 69 | | *option* | | px-4 py-1 rounded | option_class | option_extra_class | 70 | | *selected_option* | cursor-pointer font-bold | cursor-pointer font-bold hover:bg-gray-400 rounded | selected_option_class | | 71 | | *tag* | badge badge-primary p-1.5 text-sm | bg-blue-400 flex p-1 rounded-lg text-sm | tag_class | tag_extra_class | 72 | | *tags_container* | flex flex-wrap gap-1 p-1 | flex flex-wrap gap-1 p-1 | tags_container_class | tags_container_extra_class | 73 | | *text_input* | input input-bordered pr-6 w-full | disabled:bg-gray-100 disabled:placeholder:text-gray-400 disabled:text-gray-400 pr-6 rounded-md w-full | text_input_class | text_input_extra_class | 74 | | *text_input_selected* | input-primary | border-gray-600 text-gray-600 | text_input_selected_class | | 75 | | *unavailable_option* | disabled | text-gray-400 | unavailable_option_class | | 76 | 77 | For example, if you want to remove rounded borders from the options, have the active option use white text on a red background, 78 | and use green as a background color for tags instead of blue, render [live_select/1](`LiveSelect.live_select/1`) 79 | like this: 80 | 81 | ``` 82 | <.live_select 83 | form={my_form} 84 | field={my_field} 85 | id="live_select" 86 | mode={:tags} 87 | placeholder="Search for a city" 88 | active_option_class="text-white bg-red-800" 89 | option_extra_class="!rounded" 90 | tag_extra_class="!bg-blue-400 bg-green-200" /> 91 | ``` 92 | 93 | > #### Selectively removing classes from defaults {: .tip} 94 | > 95 | > You can remove classes included with the style's defaults by using the *!class_name* notation 96 | > in an *{element}_extra_class* option. For example, if a default style is `rounded-lg px-4`, 97 | > using an extra class option of `!rounded-lg text-black` will result in the following final class 98 | > being applied to the element: 99 | > 100 | > `px-4 text-black` 101 | 102 | 103 | -------------------------------------------------------------------------------- /test/live_select/class_util_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveSelect.ClassUtilTest do 2 | @moduledoc false 3 | 4 | alias LiveSelect.ClassUtil 5 | 6 | use ExUnit.Case, async: true 7 | 8 | doctest ClassUtil, import: true 9 | end 10 | -------------------------------------------------------------------------------- /test/live_select_quick_tags_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveSelectQuickTagsTest do 2 | @moduledoc false 3 | 4 | use LiveSelectWeb.ConnCase, async: true 5 | 6 | import LiveSelect.TestHelpers 7 | 8 | setup %{conn: conn} do 9 | {:ok, live, _html} = live(conn, "/?mode=quick_tags") 10 | 11 | %{live: live} 12 | end 13 | 14 | test "can select multiple options", %{live: live} do 15 | stub_options(~w(A B C D)) 16 | 17 | type(live, "ABC") 18 | 19 | select_nth_option(live, 2, method: :key) 20 | 21 | type(live, "ABC") 22 | 23 | select_nth_option(live, 4, method: :click) 24 | 25 | assert_selected_multiple(live, ~w(B D)) 26 | end 27 | 28 | test "already selected options can be deselected in the dropdown using keyboard", %{live: live} do 29 | stub_options(~w(A B C D)) 30 | 31 | type(live, "ABC") 32 | 33 | select_nth_option(live, 2) 34 | assert_selected_multiple(live, ~w(B)) 35 | 36 | type(live, "ABC") 37 | navigate(live, 3, :down) 38 | navigate(live, 1, :up) 39 | 40 | keydown(live, "Enter") 41 | assert_selected_multiple(live, ~w()) 42 | end 43 | 44 | test "already selected options can be deselected in the dropdown using mouseclick", %{ 45 | live: live 46 | } do 47 | select_and_open_dropdown(live, 2) 48 | 49 | assert_selected_multiple(live, ~w(B)) 50 | 51 | select_nth_option(live, 2, method: :click) 52 | 53 | assert_selected_multiple(live, ~w()) 54 | end 55 | 56 | test "hitting enter with only one option selects it", %{live: live} do 57 | stub_options(~w(A)) 58 | 59 | type(live, "ABC") 60 | 61 | keydown(live, "Enter") 62 | 63 | assert_selected_multiple(live, ~w(A)) 64 | end 65 | 66 | test "hitting enter with more than one option does not select", %{live: live} do 67 | stub_options(~w(A B)) 68 | 69 | type(live, "ABC") 70 | 71 | keydown(live, "Enter") 72 | 73 | assert_selected_multiple_static(live, []) 74 | end 75 | 76 | test "hitting enter with only one option does not select it if already selected", %{live: live} do 77 | stub_options(~w(A)) 78 | 79 | type(live, "ABC") 80 | 81 | select_nth_option(live, 1) 82 | 83 | assert_selected_multiple(live, ~w(A)) 84 | 85 | type(live, "ABC") 86 | 87 | keydown(live, "Enter") 88 | 89 | assert_selected_multiple_static(live, ~w(A)) 90 | end 91 | 92 | describe "when user_defined_options = true" do 93 | setup %{conn: conn} do 94 | {:ok, live, _html} = live(conn, "/?mode=tags&user_defined_options=true&update_min_len=3") 95 | %{live: live} 96 | end 97 | 98 | test "hitting enter adds entered text to selection", %{live: live} do 99 | stub_options(["A", "B"]) 100 | 101 | type(live, "ABC") 102 | 103 | keydown(live, "Enter") 104 | 105 | assert_selected_multiple(live, ["ABC"]) 106 | end 107 | 108 | test "hitting enter does not add text to selection if element with same label is already selected", 109 | %{live: live} do 110 | stub_options(["ABC", "DEF"]) 111 | 112 | type(live, "ABC") 113 | 114 | select_nth_option(live, 1, method: :key) 115 | 116 | assert_selected_multiple(live, ["ABC"]) 117 | 118 | type(live, "ABC") 119 | 120 | assert_options(live, ["ABC", "DEF"]) 121 | 122 | keydown(live, "Enter") 123 | 124 | assert_selected_multiple_static(live, ["ABC"]) 125 | end 126 | 127 | test "hitting enter adds text to selection even if there is only one available option", %{ 128 | live: live 129 | } do 130 | stub_options(["A"]) 131 | 132 | type(live, "ABC") 133 | 134 | keydown(live, "Enter") 135 | 136 | assert_selected_multiple(live, ["ABC"]) 137 | end 138 | 139 | test "text added to selection should be trimmed", %{live: live} do 140 | stub_options([]) 141 | 142 | type(live, " ABC ") 143 | 144 | keydown(live, "Enter") 145 | 146 | assert_selected_multiple_static(live, ["ABC"]) 147 | end 148 | 149 | test "text with only whitespace is ignored and not added to selection", %{live: live} do 150 | stub_options(["ABC"]) 151 | 152 | type(live, " ") 153 | 154 | keydown(live, "Enter") 155 | 156 | assert_selected_multiple_static(live, []) 157 | end 158 | 159 | test "text shorter than update_min_len is ignored and not added to selection", %{live: live} do 160 | stub_options([{"ABC", 1}, {"DEF", 2}]) 161 | 162 | type(live, "AB") 163 | 164 | keydown(live, "Enter") 165 | 166 | assert_selected_multiple_static(live, []) 167 | end 168 | 169 | test "hitting enter while options are awaiting update does not select", %{live: live} do 170 | stub_options(~w(A B C), delay_forever: true) 171 | 172 | type(live, "ABC") 173 | 174 | keydown(live, "Enter") 175 | 176 | assert_selected_multiple_static(live, []) 177 | end 178 | 179 | test "one can still select options from the dropdown", %{live: live} do 180 | stub_options(~w(A B C)) 181 | 182 | type(live, "ABC") 183 | 184 | select_nth_option(live, 1, method: :key) 185 | 186 | type(live, "ABC") 187 | 188 | select_nth_option(live, 2, method: :click) 189 | 190 | assert_selected_multiple(live, ~w(A B)) 191 | end 192 | end 193 | 194 | describe "when max_selectable option is set" do 195 | setup %{conn: conn} do 196 | {:ok, live, _html} = live(conn, "/?mode=quick_tags&max_selectable=2") 197 | 198 | %{live: live} 199 | end 200 | 201 | test "prevents selection of more than max_selectable options", %{live: live} do 202 | stub_options(~w(A B C D)) 203 | 204 | type(live, "ABC") 205 | 206 | select_nth_option(live, 2, method: :key) 207 | 208 | select_nth_option(live, 4, method: :click) 209 | 210 | assert_selected_multiple(live, ~w(B D)) 211 | 212 | select_nth_option(live, 3, method: :click) 213 | 214 | assert_selected_multiple_static(live, ~w(B D)) 215 | end 216 | 217 | test "can deselect option by clicking on option in dropdown", %{live: live} do 218 | stub_options(~w(A B C D)) 219 | 220 | type(live, "ABC") 221 | 222 | select_nth_option(live, 2, method: :key) 223 | 224 | select_nth_option(live, 4, method: :click) 225 | 226 | assert_selected_multiple(live, ~w(B D)) 227 | 228 | select_nth_option(live, 4, method: :click) 229 | 230 | assert_selected_multiple(live, ~w(B)) 231 | end 232 | 233 | test "can deselect option by navigating to it and hitting enter", %{live: live} do 234 | stub_options(~w(A B C D)) 235 | 236 | type(live, "ABC") 237 | 238 | select_nth_option(live, 2, method: :key) 239 | 240 | select_nth_option(live, 4, method: :click) 241 | 242 | assert_selected_multiple(live, ~w(B D)) 243 | 244 | select_nth_option(live, 4, method: :key) 245 | 246 | assert_selected_multiple(live, ~w(B)) 247 | end 248 | end 249 | 250 | test "can remove selected options by clicking on tag", %{live: live} do 251 | stub_options(~w(A B C D)) 252 | 253 | type(live, "ABC") 254 | 255 | select_nth_option(live, 2) 256 | 257 | type(live, "ABC") 258 | 259 | select_nth_option(live, 3) 260 | 261 | type(live, "ABC") 262 | 263 | select_nth_option(live, 1) 264 | 265 | assert_selected_multiple(live, ~w(B C A)) 266 | 267 | unselect_nth_option(live, 2) 268 | 269 | assert_selected_multiple(live, ~w(B A)) 270 | end 271 | 272 | test "can set an option as sticky so it can't be removed", %{live: live} do 273 | options = 274 | [ 275 | %{tag_label: "R", value: "Rome", sticky: true}, 276 | %{tag_label: "NY", value: "New York"} 277 | ] 278 | |> Enum.sort() 279 | 280 | sticky_pos = 281 | Enum.find_index(options, & &1[:sticky]) + 1 282 | 283 | stub_options(options) 284 | 285 | type(live, "ABC") 286 | 287 | select_nth_option(live, 1) 288 | 289 | type(live, "ABC") 290 | 291 | select_nth_option(live, 2) 292 | 293 | refute_option_removable(live, sticky_pos) 294 | 295 | assert_option_removable(live, 3 - sticky_pos) 296 | end 297 | 298 | test "can specify alternative labels for tags using maps", %{live: live} do 299 | options = 300 | [%{tag_label: "R", value: "Rome"}, %{tag_label: "NY", value: "New York"}] |> Enum.sort() 301 | 302 | stub_options(options) 303 | 304 | type(live, "ABC") 305 | 306 | select_nth_option(live, 1) 307 | 308 | type(live, "ABC") 309 | 310 | select_nth_option(live, 2) 311 | 312 | assert_selected_multiple(live, options |> Enum.map(&Map.put(&1, :label, &1.value))) 313 | end 314 | 315 | test "can specify alternative labels for tags using keywords", %{live: live} do 316 | options = 317 | [[tag_label: "R", value: "Rome"], [tag_label: "NY", value: "New York"]] |> Enum.sort() 318 | 319 | stub_options(options) 320 | 321 | type(live, "ABC") 322 | 323 | select_nth_option(live, 1) 324 | 325 | type(live, "ABC") 326 | 327 | select_nth_option(live, 2) 328 | 329 | selection = 330 | options 331 | |> Enum.map(&Map.new/1) 332 | |> Enum.map(&Map.put(&1, :label, &1[:value])) 333 | 334 | assert_selected_multiple(live, selection) 335 | end 336 | 337 | test "can be disabled", %{conn: conn} do 338 | {:ok, live, _html} = live(conn, "/?disabled=true&mode=quick_tags") 339 | 340 | stub_options(~w(A B C D)) 341 | 342 | type(live, "ABC") 343 | 344 | select_nth_option(live, 1) 345 | 346 | assert element(live, selectors()[:text_input]) 347 | |> render() 348 | |> Floki.parse_fragment!() 349 | |> Floki.attribute("disabled") == ["disabled"] 350 | 351 | assert render(live) 352 | |> Floki.parse_fragment!() 353 | |> Floki.attribute(selectors()[:hidden_input], "disabled") == ["disabled"] 354 | 355 | assert render(live) 356 | |> Floki.parse_fragment!() 357 | |> Floki.find(selectors()[:clear_tag_button]) == [] 358 | end 359 | 360 | test "can clear the selection", %{conn: conn} do 361 | {:ok, live, _html} = live(conn, "/?mode=tags") 362 | 363 | stub_options(~w(A B C D)) 364 | 365 | type(live, "ABC") 366 | 367 | select_nth_option(live, 1) 368 | 369 | type(live, "ABC") 370 | 371 | select_nth_option(live, 2, method: :click) 372 | 373 | assert_selected_multiple(live, ~w(A B)) 374 | 375 | send_update(live, value: nil) 376 | 377 | assert_selected_multiple(live, []) 378 | end 379 | 380 | test "can force the selection", %{conn: conn} do 381 | {:ok, live, _html} = live(conn, "/?mode=tags") 382 | 383 | stub_options(~w(A B C D)) 384 | 385 | type(live, "ABC") 386 | 387 | select_nth_option(live, 1) 388 | 389 | type(live, "ABC") 390 | 391 | select_nth_option(live, 2, method: :click) 392 | 393 | assert_selected_multiple(live, ~w(A B)) 394 | 395 | send_update(live, value: ~w(B C)) 396 | 397 | assert_selected_multiple(live, ~w(B C)) 398 | end 399 | 400 | test "can force the selection and options", %{conn: conn} do 401 | {:ok, live, _html} = live(conn, "/?mode=tags") 402 | 403 | stub_options(~w(A B C D)) 404 | 405 | type(live, "ABC") 406 | 407 | select_nth_option(live, 1) 408 | 409 | type(live, "ABC") 410 | 411 | select_nth_option(live, 2, method: :click) 412 | 413 | assert_selected_multiple(live, ~w(A B)) 414 | 415 | send_update(live, value: [3, 5], options: [{"C", 3}, {"D", 4}, {"E", 5}]) 416 | 417 | assert_selected_multiple(live, [%{label: "C", value: 3}, %{label: "E", value: 5}]) 418 | end 419 | 420 | test "can render custom clear button", %{conn: conn} do 421 | {:ok, live, _html} = live(conn, "/live_component_test") 422 | 423 | type(live, "Ber", 424 | component: "#my_form_city_search_custom_clear_tags_live_select_component", 425 | parent: "#form_component" 426 | ) 427 | 428 | select_nth_option(live, 1, 429 | component: "#my_form_city_search_custom_clear_tags_live_select_component" 430 | ) 431 | 432 | assert element( 433 | live, 434 | "#my_form_city_search_custom_clear_tags_live_select_component button[data-idx=0]", 435 | "custom clear button" 436 | ) 437 | |> has_element? 438 | end 439 | 440 | defp select_and_open_dropdown(live, pos) do 441 | if pos < 1 || pos > 4, do: raise("pos must be between 1 and 4") 442 | 443 | stub_options(~w(A B C D)) 444 | 445 | type(live, "ABC") 446 | 447 | select_nth_option(live, 2) 448 | 449 | type(live, "ABC") 450 | 451 | :ok 452 | end 453 | 454 | describe "when focus and blur events are set" do 455 | setup %{conn: conn} do 456 | {:ok, live, _html} = 457 | live(conn, "/?phx-focus=focus-event-for-parent&phx-blur=blur-event-for-parent&mode=tags") 458 | 459 | %{live: live} 460 | end 461 | 462 | test "focusing on the input field sends a focus event to the parent", %{live: live} do 463 | element(live, selectors()[:text_input]) 464 | |> render_focus() 465 | 466 | assert_push_event(live, "parent_event", %{ 467 | id: "my_form_city_search_live_select_component", 468 | event: "focus-event-for-parent", 469 | payload: %{id: "my_form_city_search_live_select_component"} 470 | }) 471 | end 472 | 473 | test "blurring the input field sends a blur event to the parent", %{live: live} do 474 | element(live, selectors()[:text_input]) 475 | |> render_blur() 476 | 477 | assert_push_event(live, "select", %{ 478 | id: "my_form_city_search_live_select_component", 479 | parent_event: "blur-event-for-parent" 480 | }) 481 | end 482 | 483 | test "selecting option with enter doesn't send blur event to parent", %{conn: conn} do 484 | stub_options([{"A", 1}, {"B", 2}, {"C", 3}]) 485 | 486 | {:ok, live, _html} = live(conn, "/?phx-blur=blur-event-for-parent&mode=tags") 487 | 488 | type(live, "ABC") 489 | 490 | assert_options(live, ["A", "B", "C"]) 491 | 492 | select_nth_option(live, 2, method: :key) 493 | 494 | refute_push_event(live, "select", %{ 495 | id: "my_form_city_search_live_select_component", 496 | parent_event: "blur-event-for-parent" 497 | }) 498 | end 499 | 500 | test "selecting option with click doesn't send blur event to parent", %{conn: conn} do 501 | stub_options([{"A", 1}, {"B", 2}, {"C", 3}]) 502 | 503 | {:ok, live, _html} = live(conn, "/?phx-blur=blur-event-for-parent&mode=tags") 504 | 505 | type(live, "ABC") 506 | 507 | assert_options(live, ["A", "B", "C"]) 508 | 509 | select_nth_option(live, 2, method: :click) 510 | 511 | refute_push_event(live, "select", %{ 512 | id: "my_form_city_search_live_select_component", 513 | parent_event: "blur-event-for-parent" 514 | }) 515 | end 516 | end 517 | 518 | test "selection can be updated from the form", %{conn: conn} do 519 | stub_options(%{ 520 | "A" => 1, 521 | "B" => 2, 522 | "C" => 3 523 | }) 524 | 525 | {:ok, live, _html} = live(conn, "/?mode=tags") 526 | 527 | type(live, "ABC") 528 | 529 | select_nth_option(live, 1) 530 | 531 | type(live, "ABC") 532 | 533 | select_nth_option(live, 2, method: :click) 534 | 535 | stub_options(%{"D" => 4, "E" => 5}) 536 | 537 | type(live, "DEE") 538 | 539 | select_nth_option(live, 1) 540 | 541 | render_change(live, "change", %{"my_form" => %{"city_search" => [1, 2, 4]}}) 542 | 543 | assert_selected_multiple(live, [ 544 | %{value: 1, label: "A"}, 545 | %{value: 2, label: "B"}, 546 | %{value: 4, label: "D"} 547 | ]) 548 | end 549 | 550 | test "selection recovery", %{conn: conn} do 551 | {:ok, live, _html} = live(conn, "/?mode=tags") 552 | 553 | values = [ 554 | value1 = %{"name" => "A", "pos" => [10.0, 20.0]}, 555 | value2 = %{"name" => "B", "pos" => [30.0, 40.0]}, 556 | value3 = %{"name" => "C", "pos" => [50.0, 60.0]} 557 | ] 558 | 559 | render_change(live, "change", %{ 560 | "my_form" => %{"city_search" => Phoenix.json_library().encode!(values)} 561 | }) 562 | 563 | render_hook(element(live, selectors()[:container]), "selection_recovery", [ 564 | %{label: "A", value: value1}, 565 | %{label: "B", value: value2}, 566 | %{label: "C", value: value3} 567 | ]) 568 | 569 | assert_selected_multiple_static(live, [ 570 | %{label: "A", value: value1}, 571 | %{label: "B", value: value2}, 572 | %{label: "C", value: value3} 573 | ]) 574 | end 575 | 576 | test "disabled options can't be selected", %{live: live} do 577 | stub_options([{"A", 1, true}, {"B", 2, false}, {"C", 3, false}]) 578 | 579 | type(live, "ABC") 580 | 581 | select_nth_option(live, 1, method: :click) 582 | refute_selected(live) 583 | 584 | select_nth_option(live, 2, method: :click) 585 | assert_selected_multiple(live, [%{value: 2, label: "B"}]) 586 | end 587 | end 588 | -------------------------------------------------------------------------------- /test/live_select_tags_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LiveSelectTagsTest do 2 | @moduledoc false 3 | 4 | use LiveSelectWeb.ConnCase, async: true 5 | 6 | import LiveSelect.TestHelpers 7 | 8 | setup %{conn: conn} do 9 | {:ok, live, _html} = live(conn, "/?mode=tags") 10 | 11 | %{live: live} 12 | end 13 | 14 | test "can select multiple options", %{live: live} do 15 | stub_options(~w(A B C D)) 16 | 17 | type(live, "ABC") 18 | 19 | select_nth_option(live, 2, method: :key) 20 | 21 | type(live, "ABC") 22 | 23 | select_nth_option(live, 4, method: :click) 24 | 25 | assert_selected_multiple(live, ~w(B D)) 26 | end 27 | 28 | test "already selected options are not selectable in the dropdown using keyboard", %{live: live} do 29 | stub_options(~w(A B C D)) 30 | 31 | type(live, "ABC") 32 | 33 | select_nth_option(live, 2) 34 | 35 | type(live, "ABC") 36 | navigate(live, 2, :down) 37 | keydown(live, "Enter") 38 | 39 | assert_selected_multiple(live, ~w(B C)) 40 | 41 | type(live, "ABC") 42 | navigate(live, 10, :down) 43 | navigate(live, 10, :up) 44 | keydown(live, "Enter") 45 | 46 | assert_selected_multiple(live, ~w(B C A)) 47 | end 48 | 49 | test "already selected options are not selectable in the dropdown using mouseclick", %{ 50 | live: live 51 | } do 52 | select_and_open_dropdown(live, 2) 53 | 54 | assert_selected_multiple(live, ~w(B)) 55 | 56 | assert :not_selectable = 57 | select_nth_option(live, 2, method: :click, flunk_if_not_selectable: false) 58 | end 59 | 60 | test "hitting enter with only one option selects it", %{live: live} do 61 | stub_options(~w(A)) 62 | 63 | type(live, "ABC") 64 | 65 | keydown(live, "Enter") 66 | 67 | assert_selected_multiple(live, ~w(A)) 68 | end 69 | 70 | test "hitting enter with more than one option does not select", %{live: live} do 71 | stub_options(~w(A B)) 72 | 73 | type(live, "ABC") 74 | 75 | keydown(live, "Enter") 76 | 77 | assert_selected_multiple_static(live, []) 78 | end 79 | 80 | test "hitting enter with only one option does not select it if already selected", %{live: live} do 81 | stub_options(~w(A)) 82 | 83 | type(live, "ABC") 84 | 85 | select_nth_option(live, 1) 86 | 87 | assert_selected_multiple(live, ~w(A)) 88 | 89 | type(live, "ABC") 90 | 91 | keydown(live, "Enter") 92 | 93 | assert_selected_multiple_static(live, ~w(A)) 94 | end 95 | 96 | describe "when user_defined_options = true" do 97 | setup %{conn: conn} do 98 | {:ok, live, _html} = live(conn, "/?mode=tags&user_defined_options=true&update_min_len=3") 99 | %{live: live} 100 | end 101 | 102 | test "hitting enter adds entered text to selection", %{live: live} do 103 | stub_options(["A", "B"]) 104 | 105 | type(live, "ABC") 106 | 107 | keydown(live, "Enter") 108 | 109 | assert_selected_multiple(live, ["ABC"]) 110 | end 111 | 112 | test "hitting enter does not add text to selection if element with same label is already selected", 113 | %{live: live} do 114 | stub_options(["ABC", "DEF"]) 115 | 116 | type(live, "ABC") 117 | 118 | select_nth_option(live, 1, method: :key) 119 | 120 | assert_selected_multiple(live, ["ABC"]) 121 | 122 | type(live, "ABC") 123 | 124 | assert_options(live, ["ABC", "DEF"]) 125 | 126 | keydown(live, "Enter") 127 | 128 | assert_selected_multiple_static(live, ["ABC"]) 129 | end 130 | 131 | test "hitting enter adds text to selection even if there is only one available option", %{ 132 | live: live 133 | } do 134 | stub_options(["A"]) 135 | 136 | type(live, "ABC") 137 | 138 | keydown(live, "Enter") 139 | 140 | assert_selected_multiple(live, ["ABC"]) 141 | end 142 | 143 | test "text added to selection should be trimmed", %{live: live} do 144 | stub_options([]) 145 | 146 | type(live, " ABC ") 147 | 148 | keydown(live, "Enter") 149 | 150 | assert_selected_multiple_static(live, ["ABC"]) 151 | end 152 | 153 | test "text with only whitespace is ignored and not added to selection", %{live: live} do 154 | stub_options(["ABC"]) 155 | 156 | type(live, " ") 157 | 158 | keydown(live, "Enter") 159 | 160 | assert_selected_multiple_static(live, []) 161 | end 162 | 163 | test "text shorter than update_min_len is ignored and not added to selection", %{live: live} do 164 | stub_options([{"ABC", 1}, {"DEF", 2}]) 165 | 166 | type(live, "AB") 167 | 168 | keydown(live, "Enter") 169 | 170 | assert_selected_multiple_static(live, []) 171 | end 172 | 173 | test "hitting enter while options are awaiting update does not select", %{live: live} do 174 | stub_options(~w(A B C), delay_forever: true) 175 | 176 | type(live, "ABC") 177 | 178 | keydown(live, "Enter") 179 | 180 | assert_selected_multiple_static(live, []) 181 | end 182 | 183 | test "one can still select options from the dropdown", %{live: live} do 184 | stub_options(~w(A B C)) 185 | 186 | type(live, "ABC") 187 | 188 | select_nth_option(live, 1, method: :key) 189 | 190 | type(live, "ABC") 191 | 192 | select_nth_option(live, 2, method: :click) 193 | 194 | assert_selected_multiple(live, ~w(A B)) 195 | end 196 | end 197 | 198 | describe "when max_selectable option is set" do 199 | setup %{conn: conn} do 200 | {:ok, live, _html} = live(conn, "/?mode=tags&max_selectable=2") 201 | 202 | %{live: live} 203 | end 204 | 205 | test "prevents selection of more than max_selectable options", %{live: live} do 206 | stub_options(~w(A B C D)) 207 | 208 | type(live, "ABC") 209 | 210 | select_nth_option(live, 2, method: :key) 211 | 212 | type(live, "ABC") 213 | 214 | select_nth_option(live, 4, method: :click) 215 | 216 | assert_selected_multiple(live, ~w(B D)) 217 | 218 | type(live, "ABC") 219 | 220 | select_nth_option(live, 3, method: :click) 221 | 222 | assert_selected_multiple_static(live, ~w(B D)) 223 | end 224 | 225 | test "disabled options stay disabled", %{live: live} do 226 | stub_options([{"A", 1, true}, {"B", 2, false}, {"C", 3, false}, {"D", 4, false}]) 227 | 228 | type(live, "ABC") 229 | select_nth_option(live, 2, method: :click) 230 | 231 | type(live, "ABC") 232 | select_nth_option(live, 3, method: :click) 233 | assert_selected_multiple(live, [%{value: 2, label: "B"}, %{value: 3, label: "C"}]) 234 | 235 | unselect_nth_option(live, 2) 236 | assert_selected_multiple(live, [%{value: 2, label: "B"}]) 237 | 238 | type(live, "ABC") 239 | select_nth_option(live, 1, method: :click) 240 | assert_selected_multiple(live, [%{value: 2, label: "B"}]) 241 | end 242 | end 243 | 244 | test "can remove selected options by clicking on tag", %{live: live} do 245 | stub_options(~w(A B C D)) 246 | 247 | type(live, "ABC") 248 | 249 | select_nth_option(live, 2) 250 | 251 | type(live, "ABC") 252 | 253 | select_nth_option(live, 3) 254 | 255 | type(live, "ABC") 256 | 257 | select_nth_option(live, 1) 258 | 259 | assert_selected_multiple(live, ~w(B D A)) 260 | 261 | unselect_nth_option(live, 2) 262 | 263 | assert_selected_multiple(live, ~w(B A)) 264 | end 265 | 266 | test "can set an option as sticky so it can't be removed", %{live: live} do 267 | options = 268 | [ 269 | %{tag_label: "R", value: "Rome", sticky: true}, 270 | %{tag_label: "NY", value: "New York"} 271 | ] 272 | |> Enum.sort() 273 | 274 | sticky_pos = 275 | Enum.find_index(options, & &1[:sticky]) + 1 276 | 277 | stub_options(options) 278 | 279 | type(live, "ABC") 280 | 281 | select_nth_option(live, 1) 282 | 283 | type(live, "ABC") 284 | 285 | select_nth_option(live, 2) 286 | 287 | refute_option_removable(live, sticky_pos) 288 | 289 | assert_option_removable(live, 3 - sticky_pos) 290 | end 291 | 292 | test "can specify alternative labels for tags using maps", %{live: live} do 293 | options = 294 | [%{tag_label: "R", value: "Rome"}, %{tag_label: "NY", value: "New York"}] |> Enum.sort() 295 | 296 | stub_options(options) 297 | 298 | type(live, "ABC") 299 | 300 | select_nth_option(live, 1) 301 | 302 | type(live, "ABC") 303 | 304 | select_nth_option(live, 2) 305 | 306 | assert_selected_multiple(live, options |> Enum.map(&Map.put(&1, :label, &1.value))) 307 | end 308 | 309 | test "can specify alternative labels for tags using keywords", %{live: live} do 310 | options = 311 | [[tag_label: "R", value: "Rome"], [tag_label: "NY", value: "New York"]] |> Enum.sort() 312 | 313 | stub_options(options) 314 | 315 | type(live, "ABC") 316 | 317 | select_nth_option(live, 1) 318 | 319 | type(live, "ABC") 320 | 321 | select_nth_option(live, 2) 322 | 323 | selection = 324 | options 325 | |> Enum.map(&Map.new/1) 326 | |> Enum.map(&Map.put(&1, :label, &1[:value])) 327 | 328 | assert_selected_multiple(live, selection) 329 | end 330 | 331 | test "can be disabled", %{conn: conn} do 332 | {:ok, live, _html} = live(conn, "/?disabled=true&mode=tags") 333 | 334 | stub_options(~w(A B C D)) 335 | 336 | type(live, "ABC") 337 | 338 | select_nth_option(live, 1) 339 | 340 | assert element(live, selectors()[:text_input]) 341 | |> render() 342 | |> Floki.parse_fragment!() 343 | |> Floki.attribute("disabled") == ["disabled"] 344 | 345 | assert render(live) 346 | |> Floki.parse_fragment!() 347 | |> Floki.attribute(selectors()[:hidden_input], "disabled") == ["disabled"] 348 | 349 | assert render(live) 350 | |> Floki.parse_fragment!() 351 | |> Floki.find(selectors()[:clear_tag_button]) == [] 352 | end 353 | 354 | test "can clear the selection", %{conn: conn} do 355 | {:ok, live, _html} = live(conn, "/?mode=tags") 356 | 357 | stub_options(~w(A B C D)) 358 | 359 | type(live, "ABC") 360 | 361 | select_nth_option(live, 1) 362 | 363 | type(live, "ABC") 364 | 365 | select_nth_option(live, 2, method: :click) 366 | 367 | assert_selected_multiple(live, ~w(A B)) 368 | 369 | send_update(live, value: nil) 370 | 371 | assert_selected_multiple(live, []) 372 | end 373 | 374 | test "can force the selection", %{conn: conn} do 375 | {:ok, live, _html} = live(conn, "/?mode=tags") 376 | 377 | stub_options(~w(A B C D)) 378 | 379 | type(live, "ABC") 380 | 381 | select_nth_option(live, 1) 382 | 383 | type(live, "ABC") 384 | 385 | select_nth_option(live, 2, method: :click) 386 | 387 | assert_selected_multiple(live, ~w(A B)) 388 | 389 | send_update(live, value: ~w(B C)) 390 | 391 | assert_selected_multiple(live, ~w(B C)) 392 | end 393 | 394 | test "can force the selection and options", %{conn: conn} do 395 | {:ok, live, _html} = live(conn, "/?mode=tags") 396 | 397 | stub_options(~w(A B C D)) 398 | 399 | type(live, "ABC") 400 | 401 | select_nth_option(live, 1) 402 | 403 | type(live, "ABC") 404 | 405 | select_nth_option(live, 2, method: :click) 406 | 407 | assert_selected_multiple(live, ~w(A B)) 408 | 409 | send_update(live, value: [3, 5], options: [{"C", 3}, {"D", 4}, {"E", 5}]) 410 | 411 | assert_selected_multiple(live, [%{label: "C", value: 3}, %{label: "E", value: 5}]) 412 | end 413 | 414 | test "can render custom clear button", %{conn: conn} do 415 | {:ok, live, _html} = live(conn, "/live_component_test") 416 | 417 | type(live, "Ber", 418 | component: "#my_form_city_search_custom_clear_tags_live_select_component", 419 | parent: "#form_component" 420 | ) 421 | 422 | select_nth_option(live, 1, 423 | component: "#my_form_city_search_custom_clear_tags_live_select_component" 424 | ) 425 | 426 | assert element( 427 | live, 428 | "#my_form_city_search_custom_clear_tags_live_select_component button[data-idx=0]", 429 | "custom clear button" 430 | ) 431 | |> has_element? 432 | end 433 | 434 | defp select_and_open_dropdown(live, pos) do 435 | if pos < 1 || pos > 4, do: raise("pos must be between 1 and 4") 436 | 437 | stub_options(~w(A B C D)) 438 | 439 | type(live, "ABC") 440 | 441 | select_nth_option(live, 2) 442 | 443 | type(live, "ABC") 444 | 445 | :ok 446 | end 447 | 448 | describe "when focus and blur events are set" do 449 | setup %{conn: conn} do 450 | {:ok, live, _html} = 451 | live(conn, "/?phx-focus=focus-event-for-parent&phx-blur=blur-event-for-parent&mode=tags") 452 | 453 | %{live: live} 454 | end 455 | 456 | test "focusing on the input field sends a focus event to the parent", %{live: live} do 457 | element(live, selectors()[:text_input]) 458 | |> render_focus() 459 | 460 | assert_push_event(live, "parent_event", %{ 461 | id: "my_form_city_search_live_select_component", 462 | event: "focus-event-for-parent", 463 | payload: %{id: "my_form_city_search_live_select_component"} 464 | }) 465 | end 466 | 467 | test "blurring the input field sends a blur event to the parent", %{live: live} do 468 | element(live, selectors()[:text_input]) 469 | |> render_blur() 470 | 471 | assert_push_event(live, "select", %{ 472 | id: "my_form_city_search_live_select_component", 473 | parent_event: "blur-event-for-parent" 474 | }) 475 | end 476 | 477 | test "selecting option with enter doesn't send blur event to parent", %{conn: conn} do 478 | stub_options([{"A", 1}, {"B", 2}, {"C", 3}]) 479 | 480 | {:ok, live, _html} = live(conn, "/?phx-blur=blur-event-for-parent&mode=tags") 481 | 482 | type(live, "ABC") 483 | 484 | assert_options(live, ["A", "B", "C"]) 485 | 486 | select_nth_option(live, 2, method: :key) 487 | 488 | refute_push_event(live, "select", %{ 489 | id: "my_form_city_search_live_select_component", 490 | parent_event: "blur-event-for-parent" 491 | }) 492 | end 493 | 494 | test "selecting option with click doesn't send blur event to parent", %{conn: conn} do 495 | stub_options([{"A", 1}, {"B", 2}, {"C", 3}]) 496 | 497 | {:ok, live, _html} = live(conn, "/?phx-blur=blur-event-for-parent&mode=tags") 498 | 499 | type(live, "ABC") 500 | 501 | assert_options(live, ["A", "B", "C"]) 502 | 503 | select_nth_option(live, 2, method: :click) 504 | 505 | refute_push_event(live, "select", %{ 506 | id: "my_form_city_search_live_select_component", 507 | parent_event: "blur-event-for-parent" 508 | }) 509 | end 510 | end 511 | 512 | test "selection can be updated from the form", %{conn: conn} do 513 | stub_options(%{ 514 | "A" => 1, 515 | "B" => 2, 516 | "C" => 3 517 | }) 518 | 519 | {:ok, live, _html} = live(conn, "/?mode=tags") 520 | 521 | type(live, "ABC") 522 | 523 | select_nth_option(live, 1) 524 | 525 | type(live, "ABC") 526 | 527 | select_nth_option(live, 2, method: :click) 528 | 529 | stub_options(%{"D" => 4, "E" => 5}) 530 | 531 | type(live, "DEE") 532 | 533 | select_nth_option(live, 1) 534 | 535 | render_change(live, "change", %{"my_form" => %{"city_search" => [1, 2, 4]}}) 536 | 537 | assert_selected_multiple(live, [ 538 | %{value: 1, label: "A"}, 539 | %{value: 2, label: "B"}, 540 | %{value: 4, label: "D"} 541 | ]) 542 | end 543 | 544 | test "selection recovery", %{conn: conn} do 545 | {:ok, live, _html} = live(conn, "/?mode=tags") 546 | 547 | values = [ 548 | value1 = %{"name" => "A", "pos" => [10.0, 20.0]}, 549 | value2 = %{"name" => "B", "pos" => [30.0, 40.0]}, 550 | value3 = %{"name" => "C", "pos" => [50.0, 60.0]} 551 | ] 552 | 553 | render_change(live, "change", %{ 554 | "my_form" => %{"city_search" => Phoenix.json_library().encode!(values)} 555 | }) 556 | 557 | render_hook(element(live, selectors()[:container]), "selection_recovery", [ 558 | %{label: "A", value: value1}, 559 | %{label: "B", value: value2}, 560 | %{label: "C", value: value3} 561 | ]) 562 | 563 | assert_selected_multiple_static(live, [ 564 | %{label: "A", value: value1}, 565 | %{label: "B", value: value2}, 566 | %{label: "C", value: value3} 567 | ]) 568 | end 569 | 570 | test "disabled options can't be selected", %{live: live} do 571 | stub_options([{"A", 1, true}, {"B", 2, false}, {"C", 3, false}]) 572 | 573 | type(live, "ABC") 574 | select_nth_option(live, 1, method: :click) 575 | refute_selected(live) 576 | 577 | type(live, "ABC") 578 | select_nth_option(live, 2, method: :click) 579 | assert_selected_multiple(live, [%{label: "B", value: 2}]) 580 | end 581 | end 582 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveSelectWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use LiveSelectWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | use LiveSelectWeb, :verified_routes 23 | 24 | # Import conveniences for testing with connections 25 | import Plug.Conn 26 | import Phoenix.ConnTest 27 | import Phoenix.LiveViewTest 28 | 29 | alias LiveSelectWeb.Router.Helpers, as: Routes 30 | 31 | # The default endpoint for testing 32 | @endpoint LiveSelectWeb.Endpoint 33 | end 34 | end 35 | 36 | setup _tags do 37 | {:ok, conn: Phoenix.ConnTest.build_conn()} 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/support/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule LiveSelect.TestHelpers do 2 | @moduledoc false 3 | 4 | use LiveSelectWeb.ConnCase, async: true 5 | 6 | @default_style :tailwind 7 | def default_style(), do: @default_style 8 | 9 | @expected_class [ 10 | daisyui: [ 11 | active_option: ~W(active), 12 | available_option: ~W(cursor-pointer), 13 | unavailable_option: ~W(disabled), 14 | clear_button: ~W(hidden cursor-pointer), 15 | clear_tag_button: ~W(cursor-pointer), 16 | container: ~W(dropdown dropdown-open), 17 | dropdown: 18 | ~W(dropdown-content z-[1] menu menu-compact shadow rounded-box bg-base-200 p-1 w-full), 19 | option: nil, 20 | selected_option: ~W(cursor-pointer font-bold), 21 | text_input: ~W(input input-bordered w-full pr-6), 22 | text_input_selected: ~W(input-primary), 23 | tags_container: ~W(flex flex-wrap gap-1 p-1), 24 | tag: ~W(p-1.5 text-sm badge badge-primary) 25 | ], 26 | tailwind: [ 27 | active_option: ~W(text-white bg-gray-600), 28 | available_option: ~W(cursor-pointer hover:bg-gray-400 rounded), 29 | unavailable_option: ~W(text-gray-400), 30 | clear_button: ~W(hidden cursor-pointer), 31 | clear_tag_button: ~W(cursor-pointer), 32 | container: ~W(h-full text-black relative), 33 | dropdown: ~W(absolute rounded-md shadow z-50 bg-gray-100 inset-x-0 top-full), 34 | option: ~W(rounded px-4 py-1), 35 | selected_option: ~W(cursor-pointer font-bold hover:bg-gray-400 rounded), 36 | text_input: 37 | ~W(rounded-md w-full disabled:bg-gray-100 disabled:placeholder:text-gray-400 disabled:text-gray-400 pr-6), 38 | text_input_selected: ~W(border-gray-600 text-gray-600), 39 | tags_container: ~W(flex flex-wrap gap-1 p-1), 40 | tag: ~W(p-1 text-sm rounded-lg bg-blue-400 flex) 41 | ] 42 | ] 43 | def expected_class(), do: @expected_class 44 | 45 | @override_class_option [ 46 | available_option: :available_option_class, 47 | unavailable_option: :unavailable_option_class, 48 | clear_button: :clear_button_class, 49 | clear_tag_button: :clear_tag_button_class, 50 | container: :container_class, 51 | dropdown: :dropdown_class, 52 | option: :option_class, 53 | selected_option: :selected_option_class, 54 | tag: :tag_class, 55 | tags_container: :tags_container_class, 56 | text_input: :text_input_class 57 | ] 58 | def override_class_option, do: @override_class_option 59 | 60 | @extend_class_option [ 61 | clear_button: :clear_button_extra_class, 62 | clear_tag_button: :clear_tag_button_extra_class, 63 | container: :container_extra_class, 64 | dropdown: :dropdown_extra_class, 65 | option: :option_extra_class, 66 | tag: :tag_extra_class, 67 | tags_container: :tags_container_extra_class, 68 | text_input: :text_input_extra_class 69 | ] 70 | def extend_class_option(), do: @extend_class_option 71 | 72 | @selectors [ 73 | container: "div[phx-hook=LiveSelect]", 74 | clear_button: "button[phx-click=clear]", 75 | clear_tag_button: "div[phx-hook=LiveSelect] > div:first-child > div > button", 76 | dropdown: "div[phx-hook=LiveSelect] > ul", 77 | dropdown_entries: "div[phx-hook=LiveSelect] > ul > li > div", 78 | hidden_input: "div[phx-hook=LiveSelect] input[type=hidden]", 79 | option: "div[phx-hook=LiveSelect] > ul > li > div", 80 | tags_container: "div[phx-hook=LiveSelect] > div:first-child", 81 | tag: "div[phx-hook=LiveSelect] > div:first-child > div", 82 | text_input: "input#my_form_city_search_text_input" 83 | ] 84 | def selectors(), do: @selectors 85 | 86 | @component_id "my_form_city_search_live_select_component" 87 | 88 | def select_nth_option(live, n, opts \\ []) do 89 | opts = 90 | Keyword.validate!(opts, 91 | method: :key, 92 | component: @selectors[:container], 93 | flunk_if_not_selectable: true 94 | ) 95 | 96 | component = Keyword.fetch!(opts, :component) 97 | {flunk_if_not_selectable, opts} = Keyword.pop(opts, :flunk_if_not_selectable) 98 | {method, opts} = Keyword.pop(opts, :method) 99 | 100 | case method do 101 | :key -> 102 | navigate(live, n, :down, opts) 103 | keydown(live, "Enter", opts) 104 | 105 | :click -> 106 | option_selector = "#{component} li > div[data-idx=#{n - 1}]" 107 | 108 | cond do 109 | has_element?(live, option_selector) -> 110 | element(live, component) 111 | |> render_hook("option_click", %{idx: to_string(n - 1)}) 112 | 113 | flunk_if_not_selectable -> 114 | flunk("could not find element: #{option_selector}") 115 | 116 | true -> 117 | :not_selectable 118 | end 119 | end 120 | end 121 | 122 | def unselect_nth_option(live, n) do 123 | selector = "#{@selectors[:tags_container]} button[data-idx=#{n - 1}]" 124 | 125 | if has_element?(live, selector) do 126 | element(live, @selectors[:container]) 127 | |> render_hook("option_remove", %{idx: to_string(n - 1)}) 128 | else 129 | flunk("could not find element: #{selector}") 130 | end 131 | end 132 | 133 | def keydown(live, key, opts \\ []) do 134 | opts = Keyword.validate!(opts, component: @selectors[:container]) 135 | 136 | component = Keyword.fetch!(opts, :component) 137 | 138 | element(live, component) 139 | |> render_hook("keydown", %{"key" => key}) 140 | end 141 | 142 | def dropdown_visible(live), do: has_element?(live, @selectors[:dropdown]) 143 | 144 | def stub_options(options, opts \\ []) do 145 | Mox.stub(LiveSelect.ChangeEventHandlerMock, :handle, fn params, _ -> 146 | unless opts[:delay_forever] do 147 | send( 148 | self(), 149 | {:update_live_select, params, options} 150 | ) 151 | end 152 | end) 153 | 154 | :ok 155 | end 156 | 157 | def assert_option_size(live, size) when is_integer(size) do 158 | assert_option_size(live, &(&1 == size)) 159 | end 160 | 161 | def assert_option_size(live, fun) when is_function(fun, 1) do 162 | assert render(live) 163 | |> Floki.parse_document!() 164 | |> Floki.find(@selectors[:dropdown_entries]) 165 | |> Enum.count() 166 | |> then(&fun.(&1)) 167 | end 168 | 169 | def type(live, text, opts \\ []) do 170 | opts = 171 | Keyword.validate!(opts, update_min_len: 3, component: @selectors[:container], parent: live) 172 | 173 | update_min_len = Keyword.fetch!(opts, :update_min_len) 174 | component = Keyword.fetch!(opts, :component) 175 | parent = Keyword.fetch!(opts, :parent) 176 | 177 | text = String.trim(text) 178 | 179 | if String.length(text) >= update_min_len do 180 | element(live, component) 181 | |> render_hook("change", %{ 182 | text: text 183 | }) 184 | 185 | parent = 186 | case parent do 187 | %Phoenix.LiveViewTest.View{} -> parent 188 | selector -> element(live, selector) 189 | end 190 | 191 | component_id = 192 | element(live, component) 193 | |> render() 194 | |> Floki.parse_fragment!() 195 | |> Floki.attribute("id") 196 | |> hd() 197 | 198 | parent 199 | |> render_hook("live_select_change", %{ 200 | text: text, 201 | id: component_id, 202 | field: "city_search" 203 | }) 204 | else 205 | element(live, component) 206 | |> render_hook("options_clear", %{}) 207 | end 208 | end 209 | 210 | def assert_options(rendered, elements) when is_binary(rendered) do 211 | assert rendered 212 | |> Floki.parse_document!() 213 | |> Floki.find(@selectors[:dropdown_entries]) 214 | |> Floki.text() 215 | |> String.replace(~r/\s+/, ",") 216 | |> String.trim(",") 217 | |> String.split(",") 218 | |> Enum.reject(&(&1 == "")) 219 | |> Enum.sort() == elements |> Enum.map(&to_string/1) |> Enum.sort() 220 | end 221 | 222 | def assert_options(live, elements), do: assert_options(render(live), elements) 223 | 224 | def assert_option_active(live, pos, active_class \\ "active") 225 | 226 | def assert_option_active(_live, _pos, "") do 227 | assert true 228 | end 229 | 230 | def assert_option_active(live, pos, active_class) do 231 | element_classes = 232 | render(live) 233 | |> Floki.parse_document!() 234 | |> Floki.attribute(@selectors[:dropdown_entries], "class") 235 | |> Enum.map(&String.trim/1) 236 | 237 | assert length(element_classes) >= pos 238 | 239 | for {element_class, idx} <- Enum.with_index(element_classes, 1) do 240 | if idx == pos do 241 | assert String.contains?(element_class, active_class) 242 | else 243 | refute String.contains?(element_class, active_class) 244 | end 245 | end 246 | end 247 | 248 | def assert_selected(live, label, value \\ nil) do 249 | {label, value} = assert_selected_static(live, label, value) 250 | 251 | assert_push_event(live, "select", %{ 252 | id: @component_id, 253 | selection: [%{label: ^label, value: ^value}], 254 | input_event: true, 255 | mode: :single 256 | }) 257 | end 258 | 259 | def assert_selected_static(html, label, value \\ nil) 260 | 261 | def assert_selected_static(html, label, value) when is_binary(html) do 262 | value = if value, do: value, else: label 263 | 264 | assert Floki.attribute(html, @selectors[:hidden_input], "value") == [encode_value(value)] 265 | 266 | text_input = Floki.find(html, @selectors[:text_input]) 267 | 268 | assert Floki.attribute(text_input, "value") == 269 | [to_string(label)] 270 | 271 | {label, value} 272 | end 273 | 274 | def assert_selected_static(live, label, value), 275 | do: assert_selected_static(render(live), label, value) 276 | 277 | def refute_selected(live) do 278 | hidden_input = 279 | live 280 | |> element(@selectors[:hidden_input]) 281 | |> render() 282 | |> Floki.parse_fragment!() 283 | 284 | assert hidden_input 285 | |> Floki.attribute("value") == 286 | [] 287 | end 288 | 289 | def normalize_selection(selection) do 290 | for element <- selection do 291 | if is_binary(element) || is_integer(element) || is_atom(element) do 292 | %{value: element, label: element, disabled: false} 293 | else 294 | element |> Map.put_new(:disabled, false) 295 | end 296 | end 297 | end 298 | 299 | def assert_selected_multiple_static(html, selection) when is_binary(html) do 300 | normalized_selection = normalize_selection(selection) 301 | 302 | {values, tag_labels} = 303 | normalized_selection 304 | |> Enum.map(&{&1[:value], &1[:tag_label] || &1[:label]}) 305 | |> Enum.unzip() 306 | 307 | assert Floki.attribute(html, "#{@selectors[:container]} input[type=hidden]", "value") == 308 | encode_values(values) 309 | 310 | assert html 311 | |> Floki.find(@selectors[:tag]) 312 | |> Floki.text(sep: ",") 313 | |> String.split(",") 314 | |> Enum.reject(&(&1 == "")) 315 | |> Enum.map(&String.trim/1) == 316 | tag_labels 317 | 318 | normalized_selection 319 | end 320 | 321 | def assert_selected_multiple_static(live, selection) do 322 | assert_selected_multiple_static(render(live), selection) 323 | end 324 | 325 | def assert_selected_multiple(live, selection) do 326 | normalized_selection = assert_selected_multiple_static(live, selection) 327 | 328 | assert_push_event(live, "select", %{ 329 | id: @component_id, 330 | selection: ^normalized_selection 331 | }) 332 | end 333 | 334 | def assert_selected_option_class(_html, _selected_pos, []), do: true 335 | 336 | def assert_selected_option_class(html, selected_pos, selected_class) 337 | when is_binary(html) and is_list(selected_class) do 338 | element_classes = 339 | html 340 | |> Floki.attribute("ul > li", "class") 341 | |> Enum.map(&String.trim/1) 342 | 343 | # ensure we're checking both selected and unselected elements 344 | assert length(element_classes) > selected_pos 345 | selected_class = Enum.join(selected_class, " ") 346 | 347 | for {element_class, idx} <- Enum.with_index(element_classes, 1) do 348 | if idx == selected_pos do 349 | assert element_class == selected_class 350 | else 351 | assert element_class != selected_class 352 | end 353 | end 354 | end 355 | 356 | def assert_selected_option_class(live, selected_pos, selected_class), 357 | do: assert_selected_option_class(render(live), selected_pos, selected_class) 358 | 359 | def assert_available_option_class(_html, _selected_pos, []), do: true 360 | 361 | def assert_available_option_class(html, selected_pos, available_class) 362 | when is_binary(html) and is_list(available_class) do 363 | element_classes = 364 | html 365 | |> Floki.attribute("ul > li", "class") 366 | |> Enum.map(&String.trim/1) 367 | 368 | # ensure we're checking both selected and unselected elements 369 | assert length(element_classes) > selected_pos 370 | available_class = Enum.join(available_class, " ") 371 | 372 | for {element_class, idx} <- Enum.with_index(element_classes, 1) do 373 | if idx == selected_pos do 374 | assert element_class != available_class 375 | else 376 | assert element_class == available_class 377 | end 378 | end 379 | end 380 | 381 | def assert_available_option_class(live, selected_pos, available_class), 382 | do: assert_available_option_class(render(live), selected_pos, available_class) 383 | 384 | def assert_unavailable_option_class(_html, _selected_pos, []), do: true 385 | 386 | def assert_unavailable_option_class(html, selected_pos, unavailable_class) 387 | when is_binary(html) and is_list(unavailable_class) do 388 | element_classes = 389 | html 390 | |> Floki.attribute("ul > li", "class") 391 | |> Enum.map(&String.trim/1) 392 | 393 | # ensure we're checking both selected and unselected elements 394 | assert length(element_classes) > selected_pos 395 | unavailable_class = Enum.join(unavailable_class, " ") 396 | 397 | for {element_class, idx} <- Enum.with_index(element_classes, 1) do 398 | if idx == selected_pos do 399 | assert element_class != unavailable_class 400 | else 401 | assert element_class == unavailable_class 402 | end 403 | end 404 | end 405 | 406 | def assert_unavailable_option_class(live, selected_pos, unavailable_class), 407 | do: assert_unavailable_option_class(render(live), selected_pos, unavailable_class) 408 | 409 | def assert_clear(live, input_event \\ true) do 410 | assert_clear_static(live) 411 | 412 | assert_push_event(live, "select", %{ 413 | id: @component_id, 414 | selection: [], 415 | input_event: ^input_event 416 | }) 417 | end 418 | 419 | def assert_clear_static(live) do 420 | assert live 421 | |> element(@selectors[:hidden_input]) 422 | |> render() 423 | |> Floki.parse_fragment!() 424 | |> Floki.attribute("value") == [] 425 | end 426 | 427 | def assert_option_removable(live, n) do 428 | selector = "#{@selectors[:tags_container]} button[data-idx=#{n - 1}]" 429 | 430 | assert has_element?(live, selector) 431 | end 432 | 433 | def refute_option_removable(live, n) do 434 | selector = "#{@selectors[:tags_container]} button[data-idx=#{n - 1}]" 435 | 436 | refute has_element?(live, selector) 437 | end 438 | 439 | def navigate(live, n, dir, opts \\ []) do 440 | key = 441 | case dir do 442 | :down -> "ArrowDown" 443 | :up -> "ArrowUp" 444 | end 445 | 446 | for _ <- 1..n do 447 | keydown(live, key, opts) 448 | end 449 | end 450 | 451 | def send_update(live, assigns) do 452 | Phoenix.LiveView.send_update( 453 | live.pid, 454 | LiveSelect.Component, 455 | Keyword.merge(assigns, id: @component_id) 456 | ) 457 | end 458 | 459 | defp encode_values(values) when is_list(values) do 460 | for value <- values, do: encode_value(value) 461 | end 462 | 463 | defp encode_value(value) when is_binary(value), do: value 464 | 465 | defp encode_value(value) when is_number(value) or is_atom(value), do: to_string(value) 466 | 467 | defp encode_value(value), do: Phoenix.json_library().encode!(value) 468 | end 469 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | Mox.defmock(LiveSelect.ChangeEventHandlerMock, for: LiveSelect.ChangeEventHandler.Behaviour) 4 | --------------------------------------------------------------------------------