├── .formatter.exs ├── .gitignore ├── LICENSE.md ├── README.md ├── assets ├── .babelrc ├── css │ ├── app.scss │ └── phoenix.css ├── js │ └── app.js ├── package-lock.json ├── package.json └── webpack.config.js ├── changelog.md ├── config └── config.exs ├── dev.exs ├── lib └── surface_bootstrap │ ├── aria_base.ex │ ├── breadcrumb.ex │ ├── breadcrumb │ └── item.ex │ ├── button.ex │ ├── button_group.ex │ ├── card.ex │ ├── card │ ├── card_body.ex │ ├── card_body_text.ex │ ├── card_body_title.ex │ ├── card_footer.ex │ ├── card_header.ex │ └── card_image_overlay.ex │ ├── column.ex │ ├── container.ex │ ├── dropdown.ex │ ├── dropdown.hooks.js │ ├── dropdown │ └── item.ex │ ├── form.ex │ ├── form │ ├── bootstrap_error_tag.ex │ ├── checkbox.ex │ ├── color_input.ex │ ├── date_input.ex │ ├── date_time_local_input.ex │ ├── email_input.ex │ ├── file_input.ex │ ├── input_base.ex │ ├── input_group.ex │ ├── input_group_text.ex │ ├── number_input.ex │ ├── password_input.ex │ ├── radio_button.ex │ ├── range_input.ex │ ├── select.ex │ ├── telephone_input.ex │ ├── text_area.ex │ ├── text_input.ex │ ├── text_input_base.ex │ ├── time_input.ex │ └── url_input.ex │ ├── icon.ex │ ├── justify_base.ex │ ├── modal.ex │ ├── modal.hooks.js │ ├── modal │ └── footer.ex │ ├── navbar.ex │ ├── navbar │ ├── nav_brand.ex │ ├── nav_collapse.ex │ ├── nav_item.ex │ ├── nav_item_group.ex │ ├── nav_toggler.ex │ └── nav_toggler.hooks.js │ ├── row.ex │ ├── tab.ex │ ├── tab │ └── tab_item.ex │ ├── table.ex │ ├── table │ └── column.ex │ ├── tooltip.ex │ └── tooltip.hooks.js ├── mix.exs ├── mix.lock ├── priv ├── catalogue │ ├── assets │ │ ├── css │ │ │ └── app.css │ │ └── js │ │ │ └── app.js │ ├── breadcrumb │ │ ├── example01.ex │ │ └── example02.ex │ ├── button │ │ ├── example01.ex │ │ ├── example02.ex │ │ ├── example03.ex │ │ ├── example04.ex │ │ └── playground.ex │ ├── card │ │ ├── example01.ex │ │ ├── example02.ex │ │ ├── example03.ex │ │ ├── example04.ex │ │ └── example05.ex │ ├── catalogue.ex │ ├── form │ │ ├── example01.ex │ │ ├── example02.ex │ │ ├── example03.ex │ │ ├── example04.ex │ │ ├── range_input_playground.ex │ │ └── sample_model.ex │ ├── icon │ │ └── example01.ex │ ├── modal │ │ └── playground.ex │ ├── navbar │ │ ├── example01.ex │ │ └── example02.ex │ ├── tab │ │ └── example01.ex │ ├── table │ │ ├── example01.ex │ │ └── playground.ex │ └── tooltip │ │ └── example01.ex └── font-awesome-icons.txt └── test ├── support └── conn_case.ex ├── surface_bootstrap ├── button_test.exs ├── icon_test.exs ├── modal_test.exs └── table_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix, :surface, :ecto], 3 | inputs: [ 4 | "{mix,.formatter}.exs", 5 | "{config,lib,test}/**/*.{ex,exs}", 6 | "priv/catalogue/**/*.{ex,exs}" 7 | ], 8 | surface_inputs: [ 9 | "{lib,test}/**/*.{ex,exs,sface}", 10 | "priv/catalogue/**/*.{ex,exs,sface}" 11 | ] 12 | ] 13 | -------------------------------------------------------------------------------- /.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 third-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 | surface_bootstrap-*.tar 24 | 25 | #vscode settings 26 | /.vscode/ 27 | 28 | #assets node modules 29 | /assets/node_modules 30 | 31 | #ignore autogenerated hooks 32 | /assets/js/_hooks 33 | 34 | .elixir_ls/ -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2020 Marlus Saraiva 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SurfaceBootstrap 2 | 3 | A set of simple [Surface](https://github.com/surface-ui/surface/) components 4 | based on [Bootstrap](https://getbootstrap.com/docs/5.0/getting-started/introduction/). 5 | 6 | Hex.pm: [Here](https://hex.pm/packages/surface_bootstrap) 7 | 8 | 9 | ## Components 10 | 11 | * All Form Inputs 12 | * ButtonGroup 13 | * Button 14 | * Card 15 | * Container 16 | * DropDown (requires Bootstrap Native JS) 17 | * Icon 18 | * Modal (requires Bootstrap Native JS) 19 | * NavBar 20 | * Table 21 | * Table.Column 22 | * Tabs 23 | 24 | ## Example 25 | 26 | ```jsx 27 | 28 | 29 | {{ person.id }} 30 | 31 | 32 | {{ person.name }} 33 | 34 |
35 | ``` 36 | 37 | ## Usage 38 | 39 | Add `surface_bootstrap` to the list of dependencies in `mix.exs`: 40 | 41 | ```elixir 42 | def deps do 43 | [ 44 | ... 45 | {:surface_bootstrap, "~> 0.1.0"} 46 | ] 47 | end 48 | ``` 49 | 50 | The components self-register hooks and compile to folder `_hooks/`. 51 | This location is configureable through for example: `config :surface, :compiler, hooks_output_dir: "assets/js/surface"`. 52 | 53 | 54 | ### Javascript component hooks 55 | The hooks are for the "(requires Bootstrap Native JS)" marked components above. 56 | If you do not want to use Modal or DropDown, simply skip this section. 57 | 1. Start by adding `"bootstrap.native": "^3.0.14-f",` to dependencies in `package.json` in `assets/` 58 | 2. Then do the following somewhere in your `app.js` 59 | ``` 60 | //Import hooks from Surface compiler 61 | import hooks from "./_hooks" 62 | ``` 63 | 3. Configure your LiveSocket as such: 64 | ``` 65 | let liveSocket = new LiveSocket("/live", Socket, { 66 | params: { _csrf_token: csrfToken }, 67 | hooks: hooks, 68 | dom: { 69 | onBeforeElUpdated(from, to) { 70 | if (from.isEqualNode(to)) { 71 | return false 72 | } 73 | 74 | if (from.dataset.bsnclass != undefined && from.dataset.bsnclass != "") { 75 | const classes = from.dataset.bsnclass.split(" "); 76 | classes.forEach(element => { 77 | if (!to.classList.contains(element)) { 78 | to.classList.add(element); 79 | } 80 | }); 81 | 82 | } 83 | if (from.dataset.bsnstyle == "") { 84 | to.setAttribute("style", from.getAttribute("style")); 85 | } 86 | return to; 87 | } 88 | } 89 | }) 90 | ``` 91 | 92 | If you have hooks from before, remember to merge the hooks object from Surface with your own hooks before assigning to socket. Also add your own morphdom changes as needed, but remember to `return to;` so the changes are saved. 93 | 94 | 95 | To use Bootstraps's CSS styles, choose one of the following methods: 96 | 97 | ### 1. Using CDN or downloading files 98 | 99 | Add the following line to your `layout_view.ex`: 100 | 101 | ``` 102 | 103 | ``` 104 | 105 | Or download the `.css` file and manually add it to your `priv/static/css` folder. 106 | In this case, add the following line to your `layout_view.ex`: 107 | 108 | ``` 109 | 110 | ``` 111 | 112 | You can download the css from here: https://getbootstrap.com/docs/5.0/getting-started/download/ 113 | 114 | ### 2. NPM or Yarn 115 | 116 | Add `bootstrap` to the list of dependencies in `assets/package.json`: 117 | 118 | ``` 119 | "dependencies": { 120 | ... 121 | "bootstrap": "5.0.0-beta3" 122 | } 123 | ``` 124 | 125 | ## License 126 | 127 | Copyright (c) 2020, Oliver Mulelid-Tynes. 128 | 129 | SurfaceBootstrap source code is licensed under the [MIT License](LICENSE.md). -------------------------------------------------------------------------------- /assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /assets/css/app.scss: -------------------------------------------------------------------------------- 1 | /* This file is for your main application css. */ 2 | // @import "./phoenix.css"; 3 | @import "../node_modules/nprogress/nprogress.css"; 4 | 5 | /* LiveView specific classes for your customizations */ 6 | .phx-no-feedback.invalid-feedback, 7 | .phx-no-feedback .invalid-feedback { 8 | display: none; 9 | } 10 | 11 | .phx-click-loading { 12 | opacity: 0.5; 13 | transition: opacity 1s ease-out; 14 | } 15 | 16 | .phx-disconnected { 17 | cursor: wait; 18 | } 19 | .phx-disconnected * { 20 | pointer-events: none; 21 | } 22 | 23 | .phx-modal { 24 | opacity: 1 !important; 25 | position: fixed; 26 | z-index: 1; 27 | left: 0; 28 | top: 0; 29 | width: 100%; 30 | height: 100%; 31 | overflow: auto; 32 | background-color: rgb(0, 0, 0); 33 | background-color: rgba(0, 0, 0, 0.4); 34 | } 35 | 36 | .phx-modal-content { 37 | background-color: #fefefe; 38 | margin: 15% auto; 39 | padding: 20px; 40 | border: 1px solid #888; 41 | width: 80%; 42 | } 43 | 44 | .phx-modal-close { 45 | color: #aaa; 46 | float: right; 47 | font-size: 28px; 48 | font-weight: bold; 49 | } 50 | 51 | .phx-modal-close:hover, 52 | .phx-modal-close:focus { 53 | color: black; 54 | text-decoration: none; 55 | cursor: pointer; 56 | } 57 | 58 | /* Alerts and form errors */ 59 | .alert { 60 | padding: 15px; 61 | margin-bottom: 20px; 62 | border: 1px solid transparent; 63 | border-radius: 4px; 64 | } 65 | .alert-info { 66 | color: #31708f; 67 | background-color: #d9edf7; 68 | border-color: #bce8f1; 69 | } 70 | .alert-warning { 71 | color: #8a6d3b; 72 | background-color: #fcf8e3; 73 | border-color: #faebcc; 74 | } 75 | .alert-danger { 76 | color: #a94442; 77 | background-color: #f2dede; 78 | border-color: #ebccd1; 79 | } 80 | .alert p { 81 | margin-bottom: 0; 82 | } 83 | .alert:empty { 84 | display: none; 85 | } 86 | .invalid-feedback { 87 | color: #a94442; 88 | display: block; 89 | margin: -1rem 0 2rem; 90 | } 91 | 92 | .navbar-menu .navbar-item, 93 | .navbar-link { 94 | color: #9c9c9c; 95 | } 96 | -------------------------------------------------------------------------------- /assets/css/phoenix.css: -------------------------------------------------------------------------------- 1 | /* Includes some default style for the starter application. 2 | * This can be safely deleted to start fresh. 3 | */ 4 | 5 | /* Milligram v1.3.0 https://milligram.github.io 6 | * Copyright (c) 2017 CJ Patoilo Licensed under the MIT license 7 | */ 8 | 9 | *,*:after,*:before{box-sizing:inherit}html{box-sizing:border-box;font-size:62.5%}body{color:#000000;font-family:'Helvetica', 'Arial', sans-serif;font-size:1.6em;font-weight:300;line-height:1.6}blockquote{border-left:0.3rem solid #d1d1d1;margin-left:0;margin-right:0;padding:1rem 1.5rem}blockquote *:last-child{margin-bottom:0}.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#0069d9;border:0.1rem solid #0069d9;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.8rem;letter-spacing:.1rem;line-height:3.8rem;padding:0 3.0rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:focus,.button:hover,button:focus,button:hover,input[type='button']:focus,input[type='button']:hover,input[type='reset']:focus,input[type='reset']:hover,input[type='submit']:focus,input[type='submit']:hover{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button[disabled],button[disabled],input[type='button'][disabled],input[type='reset'][disabled],input[type='submit'][disabled]{cursor:default;opacity:.5}.button[disabled]:focus,.button[disabled]:hover,button[disabled]:focus,button[disabled]:hover,input[type='button'][disabled]:focus,input[type='button'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='reset'][disabled]:hover,input[type='submit'][disabled]:focus,input[type='submit'][disabled]:hover{background-color:#0069d9;border-color:#0069d9}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{background-color:transparent;color:#0069d9}.button.button-outline:focus,.button.button-outline:hover,button.button-outline:focus,button.button-outline:hover,input[type='button'].button-outline:focus,input[type='button'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='submit'].button-outline:focus,input[type='submit'].button-outline:hover{background-color:transparent;border-color:#606c76;color:#606c76}.button.button-outline[disabled]:focus,.button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,button.button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='button'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='reset'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus,input[type='submit'].button-outline[disabled]:hover{border-color:inherit;color:#0069d9}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{background-color:transparent;border-color:transparent;color:#0069d9}.button.button-clear:focus,.button.button-clear:hover,button.button-clear:focus,button.button-clear:hover,input[type='button'].button-clear:focus,input[type='button'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='submit'].button-clear:focus,input[type='submit'].button-clear:hover{background-color:transparent;border-color:transparent;color:#606c76}.button.button-clear[disabled]:focus,.button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,button.button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='button'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='reset'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus,input[type='submit'].button-clear[disabled]:hover{color:#0069d9}code{background:#f4f5f6;border-radius:.4rem;font-size:86%;margin:0 .2rem;padding:.2rem .5rem;white-space:nowrap}pre{background:#f4f5f6;border-left:0.3rem solid #0069d9;overflow-y:hidden}pre>code{border-radius:0;display:block;padding:1rem 1.5rem;white-space:pre}hr{border:0;border-top:0.1rem solid #f4f5f6;margin:3.0rem 0}input[type='email'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],textarea,select{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent;border:0.1rem solid #d1d1d1;border-radius:.4rem;box-shadow:none;box-sizing:inherit;height:3.8rem;padding:.6rem 1.0rem;width:100%}input[type='email']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,textarea:focus,select:focus{border-color:#0069d9;outline:0}select{background:url('data:image/svg+xml;utf8,') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,')}textarea{min-height:6.5rem}label,legend{display:block;font-size:1.6rem;font-weight:700;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{display:inline-block;font-weight:normal;margin-left:.5rem}.row{display:flex;flex-direction:column;padding:0;width:100%}.row.row-no-padding{padding:0}.row.row-no-padding>.column{padding:0}.row.row-wrap{flex-wrap:wrap}.row.row-top{align-items:flex-start}.row.row-bottom{align-items:flex-end}.row.row-center{align-items:center}.row.row-stretch{align-items:stretch}.row.row-baseline{align-items:baseline}.row .column{display:block;flex:1 1 auto;margin-left:0;max-width:100%;width:100%}.row .column.column-offset-10{margin-left:10%}.row .column.column-offset-20{margin-left:20%}.row .column.column-offset-25{margin-left:25%}.row .column.column-offset-33,.row .column.column-offset-34{margin-left:33.3333%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-66,.row .column.column-offset-67{margin-left:66.6666%}.row .column.column-offset-75{margin-left:75%}.row .column.column-offset-80{margin-left:80%}.row .column.column-offset-90{margin-left:90%}.row .column.column-10{flex:0 0 10%;max-width:10%}.row .column.column-20{flex:0 0 20%;max-width:20%}.row .column.column-25{flex:0 0 25%;max-width:25%}.row .column.column-33,.row .column.column-34{flex:0 0 33.3333%;max-width:33.3333%}.row .column.column-40{flex:0 0 40%;max-width:40%}.row .column.column-50{flex:0 0 50%;max-width:50%}.row .column.column-60{flex:0 0 60%;max-width:60%}.row .column.column-66,.row .column.column-67{flex:0 0 66.6666%;max-width:66.6666%}.row .column.column-75{flex:0 0 75%;max-width:75%}.row .column.column-80{flex:0 0 80%;max-width:80%}.row .column.column-90{flex:0 0 90%;max-width:90%}.row .column .column-top{align-self:flex-start}.row .column .column-bottom{align-self:flex-end}.row .column .column-center{-ms-grid-row-align:center;align-self:center}@media (min-width: 40rem){.row{flex-direction:row;margin-left:-1.0rem;width:calc(100% + 2.0rem)}.row .column{margin-bottom:inherit;padding:0 1.0rem}}a{color:#0069d9;text-decoration:none}a:focus,a:hover{color:#606c76}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}dl dl,dl ol,dl ul,ol dl,ol ol,ol ul,ul dl,ul ol,ul ul{font-size:90%;margin:1.5rem 0 1.5rem 3.0rem}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1.0rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}table{border-spacing:0;width:100%}td,th{border-bottom:0.1rem solid #e1e1e1;padding:1.2rem 1.5rem;text-align:left}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}b,strong{font-weight:bold}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2.0rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right} 10 | 11 | /* General style */ 12 | h1{font-size: 3.6rem; line-height: 1.25} 13 | h2{font-size: 2.8rem; line-height: 1.3} 14 | h3{font-size: 2.2rem; letter-spacing: -.08rem; line-height: 1.35} 15 | h4{font-size: 1.8rem; letter-spacing: -.05rem; line-height: 1.5} 16 | h5{font-size: 1.6rem; letter-spacing: 0; line-height: 1.4} 17 | h6{font-size: 1.4rem; letter-spacing: 0; line-height: 1.2} 18 | pre{padding: 1em;} 19 | 20 | .container{ 21 | margin: 0 auto; 22 | max-width: 80.0rem; 23 | padding: 0 2.0rem; 24 | position: relative; 25 | width: 100% 26 | } 27 | select { 28 | width: auto; 29 | } 30 | 31 | /* Phoenix promo and logo */ 32 | .phx-hero { 33 | text-align: center; 34 | border-bottom: 1px solid #e3e3e3; 35 | background: #eee; 36 | border-radius: 6px; 37 | padding: 3em 3em 1em; 38 | margin-bottom: 3rem; 39 | font-weight: 200; 40 | font-size: 120%; 41 | } 42 | .phx-hero input { 43 | background: #ffffff; 44 | } 45 | .phx-logo { 46 | min-width: 300px; 47 | margin: 1rem; 48 | display: block; 49 | } 50 | .phx-logo img { 51 | width: auto; 52 | display: block; 53 | } 54 | 55 | /* Headers */ 56 | header { 57 | width: 100%; 58 | background: #fdfdfd; 59 | border-bottom: 1px solid #eaeaea; 60 | margin-bottom: 2rem; 61 | } 62 | header section { 63 | align-items: center; 64 | display: flex; 65 | flex-direction: column; 66 | justify-content: space-between; 67 | } 68 | header section :first-child { 69 | order: 2; 70 | } 71 | header section :last-child { 72 | order: 1; 73 | } 74 | header nav ul, 75 | header nav li { 76 | margin: 0; 77 | padding: 0; 78 | display: block; 79 | text-align: right; 80 | white-space: nowrap; 81 | } 82 | header nav ul { 83 | margin: 1rem; 84 | margin-top: 0; 85 | } 86 | header nav a { 87 | display: block; 88 | } 89 | 90 | @media (min-width: 40.0rem) { /* Small devices (landscape phones, 576px and up) */ 91 | header section { 92 | flex-direction: row; 93 | } 94 | header nav ul { 95 | margin: 1rem; 96 | } 97 | .phx-logo { 98 | flex-basis: 527px; 99 | margin: 2rem 1rem; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // We need to import the CSS so that webpack will load it. 2 | // The MiniCssExtractPlugin is used to separate it out into 3 | // its own CSS file. 4 | import "../css/app.scss" 5 | 6 | // webpack automatically bundles all modules in your 7 | // entry points. Those entry points can be configured 8 | // in "webpack.config.js". 9 | // 10 | // Import deps with the dep name or local files with a relative path, for example: 11 | // 12 | // import {Socket} from "phoenix" 13 | // import socket from "./socket" 14 | // 15 | import "phoenix_html" 16 | import { Socket } from "phoenix" 17 | import NProgress from "nprogress" 18 | import { LiveSocket } from "phoenix_live_view" 19 | 20 | //Import hooks from Surface compiler 21 | import hooks from "./_hooks" 22 | 23 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 24 | let liveSocket = new LiveSocket("/live", Socket, { 25 | params: { _csrf_token: csrfToken }, 26 | hooks: hooks, 27 | dom: { 28 | onBeforeElUpdated(from, to) { 29 | if (from.isEqualNode(to)) { 30 | return false 31 | } 32 | 33 | if (from.dataset.bsnclass != undefined && from.dataset.bsnclass != "") { 34 | const classes = from.dataset.bsnclass.split(" "); 35 | classes.forEach(element => { 36 | if (!to.classList.contains(element)) { 37 | to.classList.add(element); 38 | } 39 | }); 40 | 41 | } 42 | if (from.dataset.bsnstyle == "") { 43 | to.setAttribute("style", from.getAttribute("style")); 44 | } 45 | return to; 46 | } 47 | } 48 | }); 49 | 50 | // Show progress bar on live navigation and form submits 51 | window.addEventListener("phx:page-loading-start", info => NProgress.start()) 52 | window.addEventListener("phx:page-loading-stop", info => NProgress.done()) 53 | 54 | // connect if there are any LiveViews on the page 55 | liveSocket.connect() 56 | 57 | // expose liveSocket on window for web console debug logs and latency simulation: 58 | // >> liveSocket.enableDebug() 59 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 60 | // >> liveSocket.disableLatencySim() 61 | window.liveSocket = liveSocket 62 | 63 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "description": " ", 4 | "license": "MIT", 5 | "scripts": { 6 | "deploy": "webpack --mode production", 7 | "watch": "webpack --mode development --watch" 8 | }, 9 | "dependencies": { 10 | "bootstrap.native": "^3.0.14-f", 11 | "nprogress": "^0.2.0", 12 | "phoenix": "file:../deps/phoenix", 13 | "phoenix_html": "file:../deps/phoenix_html", 14 | "phoenix_live_view": "file:../deps/phoenix_live_view" 15 | }, 16 | "devDependencies": { 17 | "@babel/core": "^7.0.0", 18 | "@babel/preset-env": "^7.0.0", 19 | "babel-loader": "^8.0.0", 20 | "copy-webpack-plugin": "^5.1.1", 21 | "css-loader": "^3.4.2", 22 | "sass-loader": "^8.0.2", 23 | "node-sass": "^4.13.1", 24 | "hard-source-webpack-plugin": "^0.13.1", 25 | "mini-css-extract-plugin": "^0.9.0", 26 | "optimize-css-assets-webpack-plugin": "^5.0.1", 27 | "terser-webpack-plugin": "^2.3.2", 28 | "webpack": "4.41.5", 29 | "webpack-cli": "^3.3.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const glob = require('glob'); 3 | const HardSourceWebpackPlugin = require('hard-source-webpack-plugin'); 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | const TerserPlugin = require('terser-webpack-plugin'); 6 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 7 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 8 | 9 | module.exports = (env, options) => { 10 | const devMode = options.mode !== 'production'; 11 | 12 | return { 13 | optimization: { 14 | minimizer: [ 15 | new TerserPlugin({ cache: true, parallel: true, sourceMap: devMode }), 16 | new OptimizeCSSAssetsPlugin({}) 17 | ] 18 | }, 19 | entry: { 20 | 'app': glob.sync('./vendor/**/*.js').concat(['./js/app.js']) 21 | }, 22 | output: { 23 | filename: '[name].js', 24 | path: path.resolve(__dirname, '../priv/catalogue/assets/js'), 25 | publicPath: '/js/' 26 | }, 27 | devtool: devMode ? 'eval-cheap-module-source-map' : undefined, 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.js$/, 32 | exclude: /node_modules/, 33 | use: { 34 | loader: 'babel-loader' 35 | } 36 | }, 37 | { 38 | test: /\.[s]?css$/, 39 | use: [ 40 | MiniCssExtractPlugin.loader, 41 | 'css-loader', 42 | 'sass-loader', 43 | ], 44 | } 45 | ] 46 | }, 47 | plugins: [ 48 | new MiniCssExtractPlugin({ filename: '../css/app.css' }) 49 | ] 50 | .concat(devMode ? [new HardSourceWebpackPlugin()] : []) 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | ## v0.2.5 (2021-06-23) 3 | * Add `Breadcrumb` 4 | ## v0.2.4 (2021-06-23) 5 | * Yank 0.2.3 due to bug in Button aria properties 6 | * Fix said bug 7 | ## v0.2.3 (2021-06-22) 8 | * Upgrade to surface 0.5.0 (thank you @escobera). 9 | * Add more fine grained sizing and color styling to `Icon`. 10 | * Add `values` prop to Button that passes on to `:values` directive. 11 | * Add `opts` prop to Button to pass on `attrs` to 12 | ## v0.2.2 (2021-06-04) 13 | * Get rid of surplus `href=""` on Table column sorting links due to change in LiveView 14 | ## v0.2.1 (2021-06-03) 15 | * Get rid of stray `IO.inspect` on Button component. 16 | ## v0.2.0 (2021-05-27) 17 | 18 | * Migrate to Surface 0.4.1 (atoms not auto-cast anymore), has to be 0.4.1 or above because 0.4.0 swamps with compiler warnings due to a change in LiveView 19 | * [BREAKING CHANGE] Completely rework NavBar, look at examples to see how to use NavBar. It has been moved to only implement the outer and main components as I was unable to find a proper middleway for all the permutations. This is a BREAKING CHANGE because the old NavBar was naively done and could not support sidebars, and it also did not implement the collapser properly. 20 | * Added a naive tooltip implementation, injects a with random ID, so it might provoke rerenders. 21 | * Exposed a hook you can simply use with `:hook={{ "Hook", from: SurfaceBootstrap.Tooltip }}`. This requires you to put a unique HTML ID on your component, as this is a requirements for hooks to fire. 22 | 23 | ## v0.1.0 (2021-05-01) 24 | 25 | * I didn't bother with changelogs before 0.2.0, sorry! 26 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :phoenix, :json_library, Jason 4 | -------------------------------------------------------------------------------- /dev.exs: -------------------------------------------------------------------------------- 1 | # iex -S mix dev 2 | 3 | Logger.configure(level: :debug) 4 | defmodule Surface.Catalogue.ErrorView do 5 | use Phoenix.View, 6 | root: "lib/surface/catalogue/templates", 7 | namespace: Surface.Catalogue 8 | end 9 | # Start the catalogue server 10 | Surface.Catalogue.Server.start( 11 | reloadable_compilers: [:phoenix, :elixir, :surface], 12 | http: [port: 4001], 13 | live_reload: [ 14 | patterns: [ 15 | ~r"lib/surface_bootstrap/.*(ex|js)$" 16 | ] 17 | ] 18 | ) 19 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/aria_base.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.AriaBase do 2 | defmacro __using__(_) do 3 | quote do 4 | @doc """ 5 | Aria disabled, is set to true automatically if component 6 | has 'disabled' prop and it is true. 7 | """ 8 | prop aria_disabled, :boolean 9 | 10 | @doc """ 11 | Aria label, automatically set to label, if prop exists. 12 | Explicitly set to nil if this behaviour is unwanted. 13 | """ 14 | prop aria_label, :string 15 | 16 | @doc """ 17 | Aria hidden, automatically set to true if 'inivisble' 18 | prop is set. 19 | """ 20 | prop aria_hidden, :boolean 21 | 22 | defp set_aria_base_attrs(assigns) do 23 | Enum.reduce([:aria_disabled, :aria_label, :aria_hidden], Keyword.new(), fn 24 | :aria_disabled, acc -> 25 | Keyword.put( 26 | acc, 27 | :"aria-disabled", 28 | assigns.aria_disabled || Map.get(assigns, :disabled, nil) 29 | ) 30 | 31 | :aria_label, acc -> 32 | Keyword.put( 33 | acc, 34 | :"aria-label", 35 | assigns.aria_label || Map.get(assigns, :label, nil) 36 | ) 37 | 38 | :aria_hidden, acc -> 39 | Keyword.put( 40 | acc, 41 | :"aria-hidden", 42 | assigns.aria_hidden || Map.get(assigns, :invisible, nil) 43 | ) 44 | end) 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/breadcrumb.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Breadcrumb do 2 | use Surface.Component 3 | 4 | @moduledoc """ 5 | Can take a list of breadcrumb maps with keys `url`, `text` and `active` 6 | or a slot list of `Breadcrumb.Item`. 7 | """ 8 | 9 | alias Surface.Components.{Link, LiveRedirect, LivePatch} 10 | @doc "What kind of link to be rendered? `Link`, `LiveRedirect` or `LivePatch`." 11 | prop link_type, :string, required: true, values: ~w(link live_redirect live_patch) 12 | 13 | prop breadcrumbs, :list, default: [] 14 | slot items 15 | 16 | def render(assigns = %{breadcrumbs: [_ | _]}) do 17 | ~F""" 18 | 42 | """ 43 | end 44 | 45 | def render(assigns) do 46 | ~F""" 47 | 71 | """ 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/breadcrumb/item.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Breadcrumb.Item do 2 | use Surface.Component, slot: "items" 3 | 4 | @moduledoc """ 5 | Renderless holder of Breadcrumb.Item data. 6 | """ 7 | 8 | @doc "Breadcrumb text" 9 | prop text, :string, required: true 10 | 11 | @doc "Url to render, optional and should not be supplied on last item to grey out" 12 | prop url, :string 13 | 14 | @doc "Is this item the active one?" 15 | prop active, :boolean, default: false 16 | end 17 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/button.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Button do 2 | @moduledoc """ 3 | A Button element as defined by https://getbootstrap.com/docs/5.0/components/buttons/ 4 | """ 5 | 6 | @button_colors ~w(primary secondary success danger warning info light dark) 7 | @button_sizes ~w(small large) 8 | 9 | use Surface.Component 10 | use SurfaceBootstrap.AriaBase 11 | 12 | @doc """ 13 | The button type, defaults to "button", mainly used for instances like modal X to close style buttons 14 | where you don't want to set a type at all. Setting to nil makes button have no type. 15 | """ 16 | prop type, :string, default: "button" 17 | 18 | @doc "The label of the button, when no content (default slot) is provided" 19 | prop label, :string 20 | 21 | @doc "The color of the button" 22 | prop color, :string, values: @button_colors 23 | 24 | @doc "The size of button, setting nothing equals normal size" 25 | prop size, :string, values: @button_sizes 26 | 27 | @doc "The value for the button" 28 | prop value, :string 29 | 30 | @doc "Add multiple named values, translates to phx-value- " 31 | prop values, :keyword, default: [] 32 | 33 | @doc "Set the button as disabled preventing the user from interacting with the control" 34 | prop disabled, :boolean 35 | 36 | @doc "Outlined style" 37 | prop outlined, :boolean 38 | 39 | @doc "Rounded pill style" 40 | prop rounded, :boolean 41 | 42 | @doc "Loading state" 43 | prop loading, :boolean 44 | 45 | @doc "Text to display on button while loading, overwrites label while loading." 46 | prop loading_text, :string 47 | 48 | @doc "Should the label show when button is loading? Defaults to true." 49 | prop loading_label, :boolean, default: true 50 | 51 | @doc "Triggered on click" 52 | prop click, :event 53 | 54 | @doc "Title prop" 55 | prop title, :string 56 | 57 | @doc "Css classes to propagate down to button. Default class if no class supplied is simply _btn_" 58 | prop class, :css_class, default: [] 59 | 60 | @doc """ 61 | The content of the generated ` 97 | """ 98 | end 99 | 100 | defp button_classes(assigns) do 101 | button_class(assigns) ++ 102 | button_size(assigns) 103 | end 104 | 105 | defp button_class(assigns = %{color: color}) when color in @button_colors do 106 | cond do 107 | assigns.outlined -> 108 | ["btn-outline-#{assigns.color}"] 109 | 110 | true -> 111 | ["btn-#{assigns.color}"] 112 | end 113 | end 114 | 115 | defp button_class(_), do: [] 116 | 117 | defp button_size(%{size: size}) when size in @button_sizes do 118 | case size do 119 | "small" -> 120 | ["btn-sm"] 121 | 122 | "large" -> 123 | ["btn-lg"] 124 | end 125 | end 126 | 127 | defp button_size(_), do: [] 128 | end 129 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/button_group.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.ButtonGroup do 2 | @moduledoc """ 3 | Group of buttons. 4 | 5 | Can contain 6 | - Button components 7 | - Link Components 8 | - of type checkbox and radio 9 | 10 | https://getbootstrap.com/docs/5.0/components/button-group/ 11 | """ 12 | use Surface.Component 13 | use SurfaceBootstrap.AriaBase 14 | 15 | @doc "Vertical button group" 16 | prop vertical, :boolean 17 | 18 | @doc "Css classes to propagate down to button group." 19 | prop class, :css_class, default: [] 20 | 21 | slot default 22 | 23 | def render(assigns) do 24 | ~F""" 25 |
30 | <#slot /> 31 |
32 | """ 33 | end 34 | 35 | defp get_class(assigns) do 36 | case assigns do 37 | %{vertical: true} -> 38 | "btn-group-vertical" 39 | 40 | _ -> 41 | "btn-group" 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/card.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Card do 2 | @moduledoc """ 3 | Card component. 4 | 5 | Can hold: 6 | - Card.Body 7 | - Card.Body.Title (inside Body) 8 | - Card.Footer 9 | - List groups (https://getbootstrap.com/docs/5.0/components/list-group/) 10 | - `` (Link, LiveRedirect, LivePatch) 11 | - `` and `` 12 | 13 | https://getbootstrap.com/docs/5.0/components/card/ 14 | """ 15 | use Surface.Component 16 | 17 | @colors ~w(primary secondary success danger warning info light dark) 18 | 19 | @text_colors ~w(primary secondary success danger warning info light dark body muted white black-50 white-50) 20 | 21 | @doc "Which way to align text, will cascade to all child elements" 22 | prop text_align, :string, values: ~w(left center right) 23 | 24 | @doc "Helper to set utility classes of width in % of parent container" 25 | prop width, :string, values: ~w(25 50 75 100) 26 | 27 | @doc "Can be used to set width like `width: 18em`" 28 | prop style, :string 29 | 30 | @doc "Background color" 31 | prop background_color, :string, values: @colors 32 | 33 | @doc "Text color" 34 | prop text_color, :string, values: @text_colors 35 | 36 | @doc "Border color" 37 | prop border_color, :string, values: @colors 38 | 39 | @doc "Extra classes to put on outer div" 40 | prop class, :css_class, default: [] 41 | 42 | slot card_header 43 | slot card_footer 44 | slot default 45 | 46 | def render(assigns) do 47 | ~F""" 48 |
63 |
67 | <#slot name="card_header" /> 68 |
69 | <#slot /> 70 | 71 |
75 | <#slot name="card_footer" /> 76 |
77 |
78 | """ 79 | end 80 | 81 | defp slot_class(slot) do 82 | case slot do 83 | [%{class: class}] -> 84 | class 85 | 86 | _ -> 87 | [] 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/card/card_body.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Card.Body do 2 | @moduledoc """ 3 | Card body component 4 | 5 | https://getbootstrap.com/docs/5.0/components/card/ 6 | """ 7 | use Surface.Component 8 | 9 | slot default, required: true 10 | 11 | def render(assigns) do 12 | ~F""" 13 |
14 | <#slot /> 15 |
16 | """ 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/card/card_body_text.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Card.Body.Text do 2 | @moduledoc """ 3 | Card body text component 4 | 5 | https://getbootstrap.com/docs/5.0/components/card/ 6 | """ 7 | use Surface.Component 8 | 9 | slot default, required: true 10 | 11 | def render(assigns) do 12 | ~F""" 13 |

14 | <#slot /> 15 |

16 | """ 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/card/card_body_title.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Card.Body.Title do 2 | @moduledoc """ 3 | Card title component 4 | 5 | https://getbootstrap.com/docs/5.0/components/card/ 6 | """ 7 | use Surface.Component 8 | 9 | prop title, :string, required: true 10 | 11 | prop title_size, :string, values: ~w(1 2 3 4 5 6), default: "5" 12 | 13 | prop title_class, :css_class, default: [] 14 | 15 | prop sub_title, :string 16 | 17 | prop sub_title_size, :string, values: ~w(1 2 3 4 5 6), default: "6" 18 | 19 | prop sub_title_class, :css_class, default: [] 20 | 21 | def render(assigns) do 22 | ~F""" 23 | {raw(h_opener(@title_size, @title_class))} 24 | {@title} 25 | {raw(h_closer(@title_size))} 26 | {#if @sub_title} 27 | {raw(h_opener(@sub_title_size, @sub_title_class))} 28 | {@sub_title} 29 | {raw(h_closer(@sub_title_size))} 30 | {/if} 31 | """ 32 | end 33 | 34 | defp h_opener(title_size, title_class) do 35 | ~s() 36 | end 37 | 38 | defp h_closer(title_size) do 39 | ~s() 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/card/card_footer.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Card.Footer do 2 | @moduledoc """ 3 | Card footer slot 4 | 5 | https://getbootstrap.com/docs/5.0/components/card/#header-and-footer 6 | """ 7 | use Surface.Component, slot: "card_footer" 8 | 9 | prop class, :css_class, default: [] 10 | end 11 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/card/card_header.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Card.Header do 2 | @moduledoc """ 3 | Card header slot 4 | 5 | https://getbootstrap.com/docs/5.0/components/card/#header-and-footer 6 | """ 7 | use Surface.Component, slot: "card_header" 8 | 9 | prop class, :css_class, default: [] 10 | end 11 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/card/card_image_overlay.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Card.ImageOverlay do 2 | @moduledoc """ 3 | Card image overlay component 4 | 5 | https://getbootstrap.com/docs/5.0/components/card/#image-overlays 6 | """ 7 | use Surface.Component 8 | 9 | slot default, required: true 10 | 11 | def render(assigns) do 12 | ~F""" 13 |
14 | <#slot /> 15 |
16 | """ 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/column.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Column do 2 | @moduledoc """ 3 | Column utility component, emits a `
` wrapper for columns. 4 | 5 | 6 | https://getbootstrap.com/docs/5.0/layout/grid/ 7 | """ 8 | use Surface.Component 9 | 10 | @width_base_values ~w(1 2 3 4 5 6 7 8 9 10 11 12 auto) 11 | 12 | @doc "Css classes to propagate down to column div." 13 | prop class, :css_class, default: [] 14 | 15 | @doc "Width for extra small viewports" 16 | prop width, :string, values: @width_base_values ++ ["base"] 17 | 18 | @doc "Width for small viewports" 19 | prop sm_width, :string, values: @width_base_values 20 | 21 | @doc "Width for medium viewports" 22 | prop md_width, :string, values: @width_base_values 23 | 24 | @doc "Width for large viewports" 25 | prop lg_width, :string, values: @width_base_values 26 | 27 | @doc "Width for extra large viewports" 28 | prop xl_width, :string, values: @width_base_values 29 | 30 | @doc "Width for extra extra large viewports" 31 | prop xxl_width, :string, values: @width_base_values 32 | 33 | slot default 34 | 35 | def render(assigns) do 36 | ~F""" 37 |
46 | <#slot /> 47 |
48 | """ 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/container.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Container do 2 | @moduledoc """ 3 | A container class, lets you set breakpoints to adjust to mobile views etc. 4 | 5 | https://getbootstrap.com/docs/5.0/layout/containers/ 6 | """ 7 | use Surface.Component 8 | use SurfaceBootstrap.AriaBase 9 | 10 | @doc "Css classes to propagate down to button group." 11 | prop class, :css_class, default: [] 12 | 13 | @doc """ 14 | Container breakpoint, look at documentation from moduledoc for explanations. 15 | Unknown value or no value set defaults to 'normal' which translates to no 16 | breakpoint modifier (plain class "container"). 17 | """ 18 | prop breakpoint, :string, values: ~w(normal small medium large xl xxl fluid) 19 | 20 | slot default 21 | 22 | def render(assigns) do 23 | ~F""" 24 |
25 | <#slot /> 26 |
27 | """ 28 | end 29 | 30 | defp get_class(breakpoint) do 31 | case breakpoint do 32 | "small" -> 33 | "container-sm" 34 | 35 | "medium" -> 36 | "container-md" 37 | 38 | "large" -> 39 | "container-lg" 40 | 41 | "xl" -> 42 | "container-xl" 43 | 44 | "xll" -> 45 | "container-xxl" 46 | 47 | "fluid" -> 48 | "container-fluid" 49 | 50 | _ -> 51 | "container" 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/dropdown.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.DropDown do 2 | @moduledoc """ 3 | The dropdown component. 4 | 5 | https://getbootstrap.com/docs/5.0/components/dropdowns/ 6 | 7 | The `@wrapper` property changes the container wrapper for this component 8 | and is meant to be used to change which context the dropdown is used in. 9 | 10 | The values for `@wrapper` are: 11 | - default -- Gives a `
40 | 41 | """ 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/form/date_time_local_input.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Form.DateTimeLocalInput do 2 | @moduledoc """ 3 | The local datetime input element as defined here: 4 | - https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#datetime_local_input/3 5 | - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local 6 | """ 7 | 8 | use Surface.Component 9 | use SurfaceBootstrap.Form.InputBase 10 | 11 | alias Surface.Components.Form.DateTimeLocalInput 12 | 13 | @doc "Largest date allowed, as enforced by client browser. Not validated by Elixir." 14 | prop max, :string 15 | 16 | @doc "Earliest date allowed, as enforced by client browser. Not validated by Elixir." 17 | prop min, :string 18 | 19 | @doc """ 20 | Floating label? 21 | https://getbootstrap.com/docs/5.0/forms/floating-labels/ 22 | """ 23 | prop floating_label, :boolean 24 | 25 | def render(assigns) do 26 | ~F""" 27 | 28 | {raw(optional_div(assigns))} 29 | 30 | 37 | 38 | {help_text(assigns)} 39 | <#Raw :if={!@in_group}>
40 | 41 | """ 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/form/email_input.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Form.EmailInput do 2 | @moduledoc """ 3 | The email input element as defined here: 4 | - https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#email_input/3 5 | - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email 6 | """ 7 | 8 | use Surface.Component 9 | use SurfaceBootstrap.Form.TextInputBase 10 | 11 | alias Surface.Components.Form.EmailInput 12 | 13 | @doc "Max length of field, as enforced by client browser. Not validated by Elixir." 14 | prop maxlength, :integer 15 | 16 | @doc "Minimum length of field, as enforced by client browser. Not validated by Elixir." 17 | prop minlength, :integer 18 | 19 | def render(assigns) do 20 | ~F""" 21 | 22 | {raw(optional_div(assigns))} 23 | 24 | 31 | 32 | 33 | {help_text(assigns)} 34 | <#Raw :if={!@in_group}> 35 | 36 | """ 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/form/file_input.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Form.FileInput do 2 | @moduledoc """ 3 | The file input component as defined here: 4 | - https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#file_input/3 5 | - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/password 6 | """ 7 | 8 | use Surface.Component 9 | use SurfaceBootstrap.Form.InputBase 10 | 11 | alias Surface.Components.Form.FileInput 12 | 13 | def render(assigns) do 14 | ~F""" 15 | 16 | {raw(optional_div(assigns))} 17 | 18 | 25 | 26 | {help_text(assigns)} 27 | <#Raw :if={!@in_group}> 28 | 29 | """ 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/form/input_base.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Form.InputBase do 2 | defmacro __using__(_) do 3 | quote do 4 | import SurfaceBootstrap.Form.InputBase 5 | 6 | alias SurfaceBootstrap.Form.BootstrapErrorTag 7 | alias Surface.Components.Raw 8 | alias Surface.Components.Form.{FieldContext, Label} 9 | 10 | @doc "The the field on the changeset" 11 | prop field, :atom, required: true 12 | 13 | @doc "Pre populated value" 14 | prop value, :any 15 | 16 | @doc "The string label of the field" 17 | prop label, :string 18 | 19 | @doc "Class to apply to input" 20 | prop class, :css_class, default: [] 21 | 22 | @doc "Any opts you want to pass on to internal `input` from `Phoenix.HTML.Form`" 23 | prop opts, :keyword, default: [] 24 | 25 | @doc "Disable input" 26 | prop disabled, :boolean 27 | 28 | @doc """ 29 | Help text, will be replaced by error text if changeset gets errors. 30 | Should not be used on inputs inside `InputGroup` 31 | """ 32 | prop help_text, :string 33 | 34 | @doc "Triggered when the component loses focus" 35 | prop blur, :event, default: nil 36 | 37 | @doc "Triggered when the component receives focus" 38 | prop focus, :event 39 | 40 | @doc "Triggered when the component receives click" 41 | prop capture_click, :event 42 | 43 | @doc "Triggered when a button on the keyboard is pressed" 44 | prop keydown, :event 45 | 46 | @doc "Triggered when a button on the keyboard is released" 47 | prop keyup, :event 48 | 49 | @doc "Margin below form control, to create spacing. Defaults to 3. Is ignored if input is inside an `InputGroup`." 50 | prop spacing, :string, default: "3", values: ~w(1 2 3 4 5) 51 | 52 | @doc "Size of the input, defaults to nil(normal)" 53 | prop size, :string, values: ~w(small large) 54 | 55 | @doc "Is input in group? Set to true if used in `InputGroup`, defaults to false" 56 | prop in_group, :boolean, default: false 57 | 58 | def update(assigns, socket) do 59 | socket = assign(socket, assigns) 60 | 61 | case get_in(assigns, [:__context__, {SurfaceBootstrap.InputGroup, :in_group}]) do 62 | true -> 63 | {:ok, assign(socket, :in_group, true)} 64 | 65 | _ -> 66 | {:ok, socket} 67 | end 68 | end 69 | 70 | defp optional_div(%{in_group: true}) do 71 | "" 72 | end 73 | 74 | defp optional_div(%{in_group: in_group} = assigns) do 75 | has_left_right = Map.get(assigns, :show_value) in ["left", "right"] 76 | has_floating_label = assigns[:floating_label] && !has_left_right 77 | 78 | class = 79 | css_class( 80 | "mb-#{assigns[:spacing]}": assigns[:spacing], 81 | "form-floating": has_floating_label, 82 | "input-group": !in_group && has_left_right 83 | ) 84 | 85 | ~s(
) 86 | end 87 | 88 | defp help_text(%{help_text: nil}), do: nil 89 | 90 | defp help_text(%{help_text: text}) do 91 | """ 92 |
93 | #{raw(text)} 94 |
95 | """ 96 | end 97 | end 98 | end 99 | 100 | import SurfaceBootstrap.Form, only: [field_has_error?: 2, field_has_change?: 2] 101 | 102 | def has_error?(assigns) do 103 | %{__context__: %{{Surface.Components.Form, :form} => form}} = assigns 104 | 105 | field_has_error?(form, assigns.field) 106 | end 107 | 108 | def has_change?(assigns) do 109 | %{__context__: %{{Surface.Components.Form, :form} => form}} = assigns 110 | 111 | field_has_change?(form, assigns.field) 112 | end 113 | 114 | def input_classes(assigns) do 115 | [ 116 | "form-control", 117 | form_size(assigns[:size]), 118 | "is-invalid": has_change?(assigns) && has_error?(assigns), 119 | "is-valid": has_change?(assigns) && !has_error?(assigns), 120 | "form-control-plaintext": assigns[:readonly] && assigns[:readonly_plaintext] 121 | ] 122 | end 123 | 124 | def form_size(size) do 125 | case size do 126 | "large" -> 127 | "form-control-lg" 128 | 129 | "small" -> 130 | "form-control-sm" 131 | 132 | _ -> 133 | nil 134 | end 135 | end 136 | 137 | def default_core_input_opts(assigns) do 138 | Map.take(assigns, [ 139 | :disabled, 140 | :readonly, 141 | :max, 142 | :min, 143 | :step, 144 | :minlength, 145 | :maxlength, 146 | :rows, 147 | :placeholder 148 | ]) 149 | |> Keyword.new() 150 | end 151 | 152 | def default_surface_input_props(assigns) do 153 | Map.take(assigns, [ 154 | :blur, 155 | :focus, 156 | :capture_click, 157 | :keydown, 158 | :keyup 159 | ]) 160 | |> Keyword.new() 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/form/input_group.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Form.InputGroup do 2 | @moduledoc """ 3 | Input group 4 | 5 | https://getbootstrap.com/docs/5.0/forms/input-group/ 6 | """ 7 | use Surface.Component 8 | 9 | @doc "Margin below form group, to create spacing. Defaults to 3" 10 | prop spacing, :string, default: "3", values: ~w(1 2 3 4 5) 11 | 12 | @doc "Size of the input group, defaults to nil(normal)" 13 | prop size, :string, values: ~w(small large) 14 | 15 | @doc "Css classes to propagate down to input group." 16 | prop class, :css_class, default: [] 17 | 18 | @doc "Label for input group" 19 | prop label, :string 20 | 21 | slot default, args: [:in_group] 22 | 23 | def render(assigns) do 24 | ~F""" 25 | 26 |
32 | 33 | <#slot /> 34 | 35 |
36 | """ 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/form/input_group_text.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Form.InputGroupText do 2 | @moduledoc """ 3 | Input group text 4 | 5 | https://getbootstrap.com/docs/5.0/forms/input-group/ 6 | """ 7 | use Surface.Component 8 | 9 | slot default 10 | 11 | def render(assigns) do 12 | ~F""" 13 | 14 | <#slot /> 15 | 16 | """ 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/form/number_input.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Form.NumberInput do 2 | @moduledoc """ 3 | The number input element as defined here: 4 | - https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#number_input/3 5 | - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/number 6 | """ 7 | 8 | use Surface.Component 9 | use SurfaceBootstrap.Form.TextInputBase 10 | 11 | alias Surface.Components.Form.NumberInput 12 | 13 | @doc "Largest number allowed, as enforced by client browser. Not validated by Elixir." 14 | prop max, :integer 15 | 16 | @doc "Smallest number allowed, as enforced by client browser. Not validated by Elixir." 17 | prop min, :integer 18 | 19 | @doc "A stepping interval to use when using up and down arrows to adjust the value, as well as for validation" 20 | prop step, :integer 21 | 22 | def render(assigns) do 23 | ~F""" 24 | 25 | {raw(optional_div(assigns))} 26 | 27 | 34 | 35 | {help_text(assigns)} 36 | <#Raw :if={!@in_group}>
37 | 38 | """ 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/form/password_input.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Form.PasswordInput do 2 | @moduledoc """ 3 | The password field component as defined here: 4 | - https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#password_input/3 5 | - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/password 6 | """ 7 | 8 | use Surface.Component 9 | use SurfaceBootstrap.Form.TextInputBase 10 | 11 | alias Surface.Components.Form.PasswordInput 12 | 13 | @doc "Max length of field, as enforced by client browser. Not validated by Elixir." 14 | prop maxlength, :integer 15 | 16 | @doc "Minimum length of field, as enforced by client browser. Not validated by Elixir." 17 | prop minlength, :integer 18 | 19 | def render(assigns) do 20 | ~F""" 21 | 22 | {raw(optional_div(assigns))} 23 | 24 | 31 | 32 | 33 | {help_text(assigns)} 34 | <#Raw :if={!@in_group}> 35 | 36 | """ 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/form/radio_button.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Form.RadioButton do 2 | @moduledoc """ 3 | The radio button component as defined in https://getbootstrap.com/docs/5.0/forms/checks-radios/ 4 | """ 5 | 6 | use Surface.Component 7 | 8 | alias Surface.Components.Form.{FieldContext, RadioButton} 9 | 10 | @doc "The the field on the changeset" 11 | prop field, :atom, required: true 12 | 13 | @doc "Disable selection" 14 | prop disabled, :boolean, default: false 15 | 16 | @doc "Any opts you want to pass on to internal `Surface.RadioButton` and `Phoenix.HTML.Form.radio_button/3`" 17 | prop opts, :keyword, default: [] 18 | 19 | @doc "Move radio button to right hand side" 20 | prop radio_button_right, :boolean 21 | @doc "Show radio button inline" 22 | prop inline, :boolean 23 | 24 | @doc "Class to apply to input" 25 | prop class, :css_class, default: [] 26 | 27 | @doc """ 28 | `options` are expected to be an enumerable which will be used to 29 | generate each respective `RadioButton`. The enumerable may have: 30 | * keyword lists - each keyword list is expected to have the keys 31 | `:key` and `:value`. Additional keys such as `:disabled` may 32 | be given to customize the option 33 | * two-item tuples - where the first element is an atom, string or 34 | integer to be used as the option label and the second element is 35 | an atom, string or integer to be used as the option value 36 | * atom, string or integer - which will be used as both label and value 37 | for the generated select 38 | """ 39 | prop options, :list 40 | 41 | slot default 42 | 43 | def render(assigns) do 44 | ~F""" 45 | 46 | 47 | {#for entry <- @options} 48 |
49 | 54 | 60 | 65 |
66 | {/for} 67 |
68 |
69 | """ 70 | end 71 | 72 | defp get_key({key, _value}), do: key 73 | 74 | defp get_key(list) when is_list(list), do: Keyword.get(list, :key) 75 | 76 | defp get_key(key), do: key 77 | 78 | defp get_disabled(list) when is_list(list), do: Keyword.get(list, :disabled, false) 79 | defp get_disabled(_), do: false 80 | 81 | defp get_value({_key, value}), do: value 82 | defp get_value(list) when is_list(list), do: Keyword.get(list, :value) 83 | defp get_value(value), do: value 84 | end 85 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/form/range_input.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Form.RangeInput do 2 | @moduledoc """ 3 | The range input element as defined here: 4 | - https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#range_input/3 5 | - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/range 6 | """ 7 | 8 | use Surface.Component 9 | use SurfaceBootstrap.Form.InputBase 10 | 11 | alias Surface.Components.Form.RangeInput 12 | alias Surface.Components.Form.Input.InputContext 13 | @doc "Largest number allowed, as enforced by client browser. Not validated by Elixir." 14 | prop max, :integer 15 | 16 | @doc "Smallest number allowed, as enforced by client browser. Not validated by Elixir." 17 | prop min, :integer 18 | 19 | @doc "A stepping interval to use when using up and down arrows to adjust the value, as well as for validation" 20 | prop step, :integer 21 | 22 | @doc "Show attached range value" 23 | prop show_value, :string, values: ~w(left right) 24 | 25 | @doc """ 26 | Floating label? 27 | https://getbootstrap.com/docs/5.0/forms/floating-labels/ 28 | """ 29 | prop floating_label, :boolean 30 | 31 | def render(assigns) do 32 | ~F""" 33 | 34 | 35 | {raw(optional_div(assigns))} 36 | 37 | {#if @show_value == "left"} 38 | 39 | {Ecto.Changeset.get_field(form.source, field)} 40 | 41 | {/if} 42 | 49 | {#if @show_value == "right"} 50 | 51 | {Ecto.Changeset.get_field(form.source, field)} 52 | 53 | {/if} 54 | 55 | {help_text(assigns)} 56 | <#Raw :if={!@in_group}> 57 | 58 | """ 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/surface_bootstrap/form/select.ex: -------------------------------------------------------------------------------- 1 | defmodule SurfaceBootstrap.Form.Select do 2 | @moduledoc """ 3 | The select component as defined here: 4 | - https://getbootstrap.com/docs/5.0/forms/select/ 5 | - https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#select/4 6 | - https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#multiple_select/4 7 | """ 8 | 9 | use Surface.Component 10 | 11 | alias Surface.Components.Form.{Field, Label, MultipleSelect, Select} 12 | 13 | @doc "The the field on the changeset" 14 | prop field, :atom, required: true 15 | 16 | @doc "The string label of the field" 17 | prop label, :string 18 | 19 | @doc "Disable selection" 20 | prop disabled, :boolean, default: false 21 | 22 | @doc "Any opts you want to pass on to internal `Surface.Checkbox` and `Phoenix.HTML.Form.checkbox/3`" 23 | prop opts, :keyword, default: [] 24 | 25 | @doc "Class to apply to input" 26 | prop class, :css_class, default: [] 27 | 28 | @doc "The select options" 29 | prop options, :any, default: [] 30 | 31 | @doc """ 32 | The selected value. 33 | For multiple selects this has to be a list that matches the value options. 34 | """ 35 | prop selected, :any 36 | 37 | @doc "The prompt (nothing selected yet) string, is ignored for multiple selects." 38 | prop prompt, :string 39 | 40 | @doc "Margin below form control, to create spacing. Defaults to 3" 41 | prop spacing, :string, default: "3", values: ~w(1 2 3 4 5) 42 | 43 | @doc "Size of the input, defaults to nil(normal)" 44 | prop size, :string, values: ~w(small large) 45 | 46 | @doc "Select size, how many options visible before scroll" 47 | prop select_size, :integer 48 | 49 | @doc "Multiple Select" 50 | prop multiple, :boolean 51 | 52 | @doc """ 53 | Floating label? 54 | https://getbootstrap.com/docs/5.0/forms/floating-labels/ 55 | """ 56 | prop floating_label, :boolean 57 | 58 | @doc "Is input in group? Set to true to hide label if used in `InputGroup`, defaults to false" 59 | prop in_group, :boolean, default: false 60 | 61 | def render(assigns) do 62 | ~F""" 63 | 64 | 65 | {#if @multiple} 66 | 77 | {/if} 78 | {#if !@multiple} 79 |