├── .coveralls.yml ├── .formatter.exs ├── .gitignore ├── .tool-versions ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── config └── config.exs ├── example ├── .formatter.exs ├── .gitignore ├── README.md ├── assets │ ├── .babelrc │ ├── css │ │ └── app.scss │ ├── js │ │ ├── app.js │ │ └── socket.js │ ├── package-lock.json │ ├── package.json │ ├── static │ │ ├── favicon.ico │ │ ├── images │ │ │ └── phoenix.png │ │ └── robots.txt │ └── webpack.config.js ├── config │ ├── config.exs │ ├── dev.exs │ ├── prod.exs │ └── test.exs ├── lib │ ├── example.ex │ ├── example │ │ └── application.ex │ ├── example_web.ex │ └── example_web │ │ ├── channels │ │ └── user_socket.ex │ │ ├── controllers │ │ └── page_controller.ex │ │ ├── endpoint.ex │ │ ├── gettext.ex │ │ ├── router.ex │ │ ├── templates │ │ ├── layout │ │ │ └── app.html.eex │ │ └── page │ │ │ └── index.html.eex │ │ └── views │ │ ├── error_helpers.ex │ │ ├── error_view.ex │ │ ├── layout_view.ex │ │ └── page_view.ex ├── mix.exs ├── mix.lock ├── priv │ └── gettext │ │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ │ └── errors.pot └── test │ ├── example_web │ ├── controllers │ │ └── page_controller_test.exs │ └── views │ │ ├── error_view_test.exs │ │ ├── layout_view_test.exs │ │ └── page_view_test.exs │ ├── support │ ├── channel_case.ex │ └── conn_case.ex │ └── test_helper.exs ├── lib ├── harmonium.ex └── harmonium │ └── table.ex ├── mix.exs ├── mix.lock └── test ├── harmonium └── table_test.exs ├── harmonium_test.exs └── test_helper.exs /.coveralls.yml: -------------------------------------------------------------------------------- 1 | multi: 2 | excoveralls: cover/excoveralls.json 3 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.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 | harmonium-*.tar 24 | 25 | # vscode stuff 26 | .elixir_ls 27 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 24.0.5 2 | elixir 1.12.2-otp-24 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: elixir 3 | elixir: 4 | - 1.12 5 | otp_release: 6 | - 24.0 7 | cache: 8 | directories: 9 | - _build 10 | - deps 11 | env: 12 | global: 13 | - HEX_USERNAME=jwietelmann 14 | install: 15 | - gem install coveralls-multi --no-document 16 | - mix local.hex --force 17 | - mix local.rebar --force 18 | - mix deps.get 19 | script: 20 | - MIX_ENV=test mix coveralls.json 21 | - coveralls-multi 22 | before_deploy: 23 | - mix compile 24 | deploy: 25 | skip_cleanup: true 26 | # https://docs.travis-ci.com/user/deployment/script/ 27 | # > `script` must be a scalar pointing to an executable file or command. 28 | provider: script 29 | # http://yaml.org/spec/1.2/spec.html#id2779048 30 | # `>-` indicates the line folding. 31 | script: mix hex.publish --yes 32 | on: 33 | tags: true 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.1.0] - 2018-06-13 11 | 12 | ### Added 13 | 14 | * First published release 15 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at support@revelry.co. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing and Development 2 | 3 | ## Development Setup 4 | 5 | * Clone repository 6 | * `mix deps.get` to get dependencies 7 | * `mix compile` to compile project 8 | * `mix test` to run tests 9 | 10 | ## Submitting Changes 11 | 12 | 1. Fork the project 13 | 2. Create a new topic branch to contain your feature, change, or fix. 14 | 3. Make sure all the tests are still passing. 15 | 4. Implement your feature, change, or fix. Make sure to write tests, update and/or add documentation. 16 | 5. Push your topic branch up to your fork. 17 | 6. Open a Pull Request with a clear title and description. 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Revelry Labs LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 4 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation 5 | the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 6 | and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions 9 | of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 12 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 13 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 14 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 15 | DEALINGS IN THE SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Harmonium 2 | 3 | [![Build Status](https://travis-ci.org/revelrylabs/phoenix_harmonium.svg?branch=master)](https://travis-ci.org/revelrylabs/phoenix_harmonium) 4 | [![Hex.pm](https://img.shields.io/hexpm/dt/harmonium.svg)](https://hex.pm/packages/harmonium) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 6 | [![Coverage Status](https://opencov.prod.revelry.net/projects/7/badge.svg)](https://opencov.prod.revelry.net/projects/7) 7 | 8 | Phoenix Framework view helpers for Harmonium-styled HTML without React. 9 | 10 | Harmonium was originally conceived as a set of styled React components ([harmonium.revelry.co](https://harmonium.revelry.co/)). This library brings the style and markup framework of Harmonium into Phoenix, but leaves the JavaScript behind. 11 | 12 | Here are a just few of the features: 13 | 14 | - Flexible grid layout system 15 | - Enhanced form helpers that decorate your inputs with errors and help text in a uniform way 16 | - Functions for inserting just the right CSS class names, for those moments when you want to go off the pre-built path 17 | 18 | ## Installation 19 | 20 | Add `harmonium` to your list of dependencies in `mix.exs` before running `mix deps.get`: 21 | 22 | ```elixir 23 | def deps do 24 | [ 25 | {:harmonium, "~> 2.1.1"} 26 | ] 27 | end 28 | ``` 29 | 30 | Then, in `config.exs`, pass in your Phoenix application's error translator function: 31 | 32 | ``` 33 | config :harmonium, 34 | error_helper: {YourAppWeb.ErrorHelpers, :translate_error} 35 | ``` 36 | 37 | From your app's root directory, run this command to get the `harmonium` package from NPM, which contains the SCSS you'll need: 38 | 39 | ```bash 40 | $(cd assets && npm install --save harmonium) 41 | ``` 42 | 43 | In `assets/app.scss`, import the SCSS: 44 | 45 | ```scss 46 | @import '~harmonium/scss/app'; 47 | ``` 48 | 49 | For more details, and a set of Starter Settings for configuring Harmonium styles, go to [harmonium.revelry.co](https://harmonium.revelry.co/) 50 | 51 | ## Example Usage 52 | 53 | Checkout the [documentation](https://hexdocs.pm/harmonium) for more examples and a full list of functions. 54 | 55 | ```elixir 56 | <%= form_for @changeset, @action, fn f -> %> 57 | <%= row do %> 58 | <%= col medium: 6, large: 4 do %> 59 | <%= text_input_stack f, :username, label: "Username", help: "Pick a good one. You can't change it later." %> 60 | <% end %> 61 | <%= col medium: 6, large: 4 do %> 62 | <%= password_input_stack f, :password, label: "Password", help: "Make it strong!" %> 63 | <% end %> 64 | <%= col medium: 6, large: 4 do %> 65 | <%= password_input_stack f, :password, label: "Password (confirm)", help: "Type it again." %> 66 | <% end %> 67 | <% end %> 68 | <%= row do %> 69 | <%= col do %> 70 | <%= single_checkbox f, :subscribe_to_newsletter, label: "Please, please, please subscribe to my newsletter." %> 71 | <% end %> 72 | <% end %> 73 | <%= row do %> 74 | <%= col do %> 75 | <%= submit "Save", class: button_class(expanded: true) %> 76 | <% end %> 77 | <% end %> 78 | <% end %> 79 | ``` 80 | 81 | ## Making a new release 82 | 83 | To deploy and update to the harmonium hex package, you first need to increment version number in `mix.exs`. Afterwards all that needs to be done is to create a new release tag for the new version number, and Travis should deploy the package automatically. 84 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :harmonium, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:harmonium, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /example/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix], 3 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /example/.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 | example-*.tar 24 | 25 | # If NPM crashes, it generates a log, let's ignore it too. 26 | npm-debug.log 27 | 28 | # The directory NPM downloads your dependencies sources to. 29 | /assets/node_modules/ 30 | 31 | # Since we are building assets from assets/, 32 | # we ignore priv/static. You may want to comment 33 | # this depending on your deployment strategy. 34 | /priv/static/ 35 | 36 | # Files matching config/*.secret.exs pattern contain sensitive 37 | # data and you should not commit them into version control. 38 | # 39 | # Alternatively, you may comment the line below and commit the 40 | # secrets files as long as you replace their contents by environment 41 | # variables. 42 | /config/*.secret.exs 43 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | To start your Phoenix server: 4 | 5 | * Install dependencies with `mix deps.get` 6 | * Install Node.js dependencies with `cd assets && npm install` 7 | * Start Phoenix endpoint with `mix phx.server` 8 | 9 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 10 | 11 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). 12 | 13 | ## Learn more 14 | 15 | * Official website: http://www.phoenixframework.org/ 16 | * Guides: https://hexdocs.pm/phoenix/overview.html 17 | * Docs: https://hexdocs.pm/phoenix 18 | * Mailing list: http://groups.google.com/group/phoenix-talk 19 | * Source: https://github.com/phoenixframework/phoenix 20 | -------------------------------------------------------------------------------- /example/assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /example/assets/css/app.scss: -------------------------------------------------------------------------------- 1 | @import '~harmonium/scss/app'; 2 | -------------------------------------------------------------------------------- /example/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 dependencies 11 | // 12 | //import "phoenix_html" 13 | 14 | // Import local files 15 | // 16 | // Local files can be imported directly using relative paths, for example: 17 | // import socket from "./socket" 18 | -------------------------------------------------------------------------------- /example/assets/js/socket.js: -------------------------------------------------------------------------------- 1 | // NOTE: The contents of this file will only be executed if 2 | // you uncomment its entry in "assets/js/app.js". 3 | 4 | // To use Phoenix channels, the first step is to import Socket, 5 | // and connect at the socket path in "lib/web/endpoint.ex". 6 | // 7 | // Pass the token on params as below. Or remove it 8 | // from the params if you are not using authentication. 9 | import {Socket} from "phoenix" 10 | 11 | let socket = new Socket("/socket", {params: {token: window.userToken}}) 12 | 13 | // When you connect, you'll often need to authenticate the client. 14 | // For example, imagine you have an authentication plug, `MyAuth`, 15 | // which authenticates the session and assigns a `:current_user`. 16 | // If the current user exists you can assign the user's token in 17 | // the connection for use in the layout. 18 | // 19 | // In your "lib/web/router.ex": 20 | // 21 | // pipeline :browser do 22 | // ... 23 | // plug MyAuth 24 | // plug :put_user_token 25 | // end 26 | // 27 | // defp put_user_token(conn, _) do 28 | // if current_user = conn.assigns[:current_user] do 29 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id) 30 | // assign(conn, :user_token, token) 31 | // else 32 | // conn 33 | // end 34 | // end 35 | // 36 | // Now you need to pass this token to JavaScript. You can do so 37 | // inside a script tag in "lib/web/templates/layout/app.html.eex": 38 | // 39 | // 40 | // 41 | // You will need to verify the user token in the "connect/3" function 42 | // in "lib/web/channels/user_socket.ex": 43 | // 44 | // def connect(%{"token" => token}, socket, _connect_info) do 45 | // # max_age: 1209600 is equivalent to two weeks in seconds 46 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do 47 | // {:ok, user_id} -> 48 | // {:ok, assign(socket, :user, user_id)} 49 | // {:error, reason} -> 50 | // :error 51 | // end 52 | // end 53 | // 54 | // Finally, connect to the socket: 55 | socket.connect() 56 | 57 | // Now that you are connected, you can join channels with a topic: 58 | let channel = socket.channel("topic:subtopic", {}) 59 | channel.join() 60 | .receive("ok", resp => { console.log("Joined successfully", resp) }) 61 | .receive("error", resp => { console.log("Unable to join", resp) }) 62 | 63 | export default socket 64 | -------------------------------------------------------------------------------- /example/assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "license": "MIT", 4 | "scripts": { 5 | "deploy": "webpack --mode production", 6 | "watch": "webpack --mode development --watch" 7 | }, 8 | "dependencies": { 9 | "harmonium": "^4.3.2", 10 | "phoenix": "file:../deps/phoenix", 11 | "phoenix_html": "file:../deps/phoenix_html" 12 | }, 13 | "devDependencies": { 14 | "@babel/core": "^7.0.0", 15 | "@babel/preset-env": "^7.0.0", 16 | "babel-loader": "^8.0.0", 17 | "copy-webpack-plugin": "^4.5.0", 18 | "css-loader": "^0.28.10", 19 | "js-yaml": ">=3.13.1", 20 | "mem": ">=4.0.0", 21 | "mini-css-extract-plugin": "^0.4.0", 22 | "node-sass": "^4.10.0", 23 | "optimize-css-assets-webpack-plugin": "^4.0.0", 24 | "sass-loader": "^7.1.0", 25 | "uglifyjs-webpack-plugin": "^1.2.4", 26 | "webpack": "4.4.0", 27 | "webpack-cli": "^2.0.10" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /example/assets/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revelrylabs/phoenix_harmonium/f473295d93c9dcb5886cc71f6b1b2e3d097a2335/example/assets/static/favicon.ico -------------------------------------------------------------------------------- /example/assets/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/revelrylabs/phoenix_harmonium/f473295d93c9dcb5886cc71f6b1b2e3d097a2335/example/assets/static/images/phoenix.png -------------------------------------------------------------------------------- /example/assets/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://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 | -------------------------------------------------------------------------------- /example/assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const glob = require('glob'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 5 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 6 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 7 | 8 | module.exports = (env, options) => ({ 9 | optimization: { 10 | minimizer: [ 11 | new UglifyJsPlugin({ cache: true, parallel: true, sourceMap: false }), 12 | new OptimizeCSSAssetsPlugin({}) 13 | ] 14 | }, 15 | entry: { 16 | './js/app.js': ['./js/app.js'].concat(glob.sync('./vendor/**/*.js')) 17 | }, 18 | output: { 19 | filename: 'app.js', 20 | path: path.resolve(__dirname, '../priv/static/js') 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.js$/, 26 | exclude: /node_modules/, 27 | use: { 28 | loader: 'babel-loader' 29 | } 30 | }, 31 | { 32 | test: /\.(css|scss|sass)$/, 33 | use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'] 34 | } 35 | ] 36 | }, 37 | plugins: [ 38 | new MiniCssExtractPlugin({ filename: '../css/app.css' }), 39 | new CopyWebpackPlugin([{ from: 'static/', to: '../' }]) 40 | ] 41 | }); 42 | -------------------------------------------------------------------------------- /example/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.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 | use Mix.Config 9 | 10 | # Configures the endpoint 11 | config :example, ExampleWeb.Endpoint, 12 | url: [host: "localhost"], 13 | secret_key_base: "APXyEcfgq9exE+xPaSjh8pxBfzHTTxLMfaaN5hHR23e7EEwG+xYXRyBcFkFWrNg1", 14 | render_errors: [view: ExampleWeb.ErrorView, accepts: ~w(html json)], 15 | pubsub: [name: Example.PubSub, adapter: Phoenix.PubSub.PG2] 16 | 17 | # Configures Elixir's Logger 18 | config :logger, :console, 19 | format: "$time $metadata[$level] $message\n", 20 | metadata: [:request_id] 21 | 22 | # Use Jason for JSON parsing in Phoenix 23 | config :phoenix, :json_library, Jason 24 | 25 | config :harmonium, 26 | error_helper: {ExampleWeb.ErrorHelpers, :translate_error} 27 | 28 | # Import environment specific config. This must remain at the bottom 29 | # of this file so it overrides the configuration defined above. 30 | import_config "#{Mix.env()}.exs" 31 | -------------------------------------------------------------------------------- /example/config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.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 webpack to recompile .js and .css sources. 9 | config :example, ExampleWeb.Endpoint, 10 | http: [port: 4000], 11 | debug_errors: true, 12 | code_reloader: true, 13 | check_origin: false, 14 | watchers: [ 15 | node: [ 16 | "node_modules/webpack/bin/webpack.js", 17 | "--mode", 18 | "development", 19 | "--watch-stdin", 20 | cd: Path.expand("../assets", __DIR__) 21 | ] 22 | ] 23 | 24 | # ## SSL Support 25 | # 26 | # In order to use HTTPS in development, a self-signed 27 | # certificate can be generated by running the following 28 | # Mix task: 29 | # 30 | # mix phx.gen.cert 31 | # 32 | # Note that this task requires Erlang/OTP 20 or later. 33 | # Run `mix help phx.gen.cert` for more information. 34 | # 35 | # The `http:` config above can be replaced with: 36 | # 37 | # https: [ 38 | # port: 4001, 39 | # cipher_suite: :strong, 40 | # keyfile: "priv/cert/selfsigned_key.pem", 41 | # certfile: "priv/cert/selfsigned.pem" 42 | # ], 43 | # 44 | # If desired, both `http:` and `https:` keys can be 45 | # configured to run both http and https servers on 46 | # different ports. 47 | 48 | # Watch static and templates for browser reloading. 49 | config :example, ExampleWeb.Endpoint, 50 | live_reload: [ 51 | patterns: [ 52 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, 53 | ~r{priv/gettext/.*(po)$}, 54 | ~r{lib/example_web/views/.*(ex)$}, 55 | ~r{lib/example_web/templates/.*(eex)$} 56 | ] 57 | ] 58 | 59 | # Do not include metadata nor timestamps in development logs 60 | config :logger, :console, format: "[$level] $message\n" 61 | 62 | # Set a higher stacktrace during development. Avoid configuring such 63 | # in production as building large stacktraces may be expensive. 64 | config :phoenix, :stacktrace_depth, 20 65 | 66 | # Initialize plugs at runtime for faster development compilation 67 | config :phoenix, :plug_init_mode, :runtime 68 | -------------------------------------------------------------------------------- /example/config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.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 :example, ExampleWeb.Endpoint, 13 | http: [:inet6, port: System.get_env("PORT") || 4000], 14 | url: [host: "example.com", port: 80], 15 | cache_static_manifest: "priv/static/cache_manifest.json" 16 | 17 | # Do not print debug messages in production 18 | config :logger, level: :info 19 | 20 | # ## SSL Support 21 | # 22 | # To get SSL working, you will need to add the `https` key 23 | # to the previous section and set your `:url` port to 443: 24 | # 25 | # config :example, ExampleWeb.Endpoint, 26 | # ... 27 | # url: [host: "example.com", port: 443], 28 | # https: [ 29 | # :inet6, 30 | # port: 443, 31 | # cipher_suite: :strong, 32 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 33 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 34 | # ] 35 | # 36 | # The `cipher_suite` is set to `:strong` to support only the 37 | # latest and more secure SSL ciphers. This means old browsers 38 | # and clients may not be supported. You can set it to 39 | # `:compatible` for wider support. 40 | # 41 | # `:keyfile` and `:certfile` expect an absolute path to the key 42 | # and cert in disk or a relative path inside priv, for example 43 | # "priv/ssl/server.key". For all supported SSL configuration 44 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 45 | # 46 | # We also recommend setting `force_ssl` in your endpoint, ensuring 47 | # no data is ever sent via http, always redirecting to https: 48 | # 49 | # config :example, ExampleWeb.Endpoint, 50 | # force_ssl: [hsts: true] 51 | # 52 | # Check `Plug.SSL` for all available options in `force_ssl`. 53 | 54 | # ## Using releases (distillery) 55 | # 56 | # If you are doing OTP releases, you need to instruct Phoenix 57 | # to start the server for all endpoints: 58 | # 59 | # config :phoenix, :serve_endpoints, true 60 | # 61 | # Alternatively, you can configure exactly which server to 62 | # start per endpoint: 63 | # 64 | # config :example, ExampleWeb.Endpoint, server: true 65 | # 66 | # Note you can't rely on `System.get_env/1` when using releases. 67 | # See the releases documentation accordingly. 68 | 69 | # Finally import the config/prod.secret.exs which should be versioned 70 | # separately. 71 | import_config "prod.secret.exs" 72 | -------------------------------------------------------------------------------- /example/config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :example, ExampleWeb.Endpoint, 6 | http: [port: 4002], 7 | server: false 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | -------------------------------------------------------------------------------- /example/lib/example.ex: -------------------------------------------------------------------------------- 1 | defmodule Example do 2 | @moduledoc """ 3 | Example keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /example/lib/example/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Example.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 | def start(_type, _args) do 9 | # List all child processes to be supervised 10 | children = [ 11 | # Start the endpoint when the application starts 12 | ExampleWeb.Endpoint 13 | # Starts a worker by calling: Example.Worker.start_link(arg) 14 | # {Example.Worker, arg}, 15 | ] 16 | 17 | # See https://hexdocs.pm/elixir/Supervisor.html 18 | # for other strategies and supported options 19 | opts = [strategy: :one_for_one, name: Example.Supervisor] 20 | Supervisor.start_link(children, opts) 21 | end 22 | 23 | # Tell Phoenix to update the endpoint configuration 24 | # whenever the application is updated. 25 | def config_change(changed, _new, removed) do 26 | ExampleWeb.Endpoint.config_change(changed, removed) 27 | :ok 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /example/lib/example_web.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb 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 ExampleWeb, :controller 9 | use ExampleWeb, :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 controller do 21 | quote do 22 | use Phoenix.Controller, namespace: ExampleWeb 23 | 24 | import Plug.Conn 25 | import ExampleWeb.Gettext 26 | alias ExampleWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/example_web/templates", 34 | namespace: ExampleWeb 35 | 36 | # Import convenience functions from controllers 37 | import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1] 38 | 39 | # Use all HTML functionality (forms, tags, etc) 40 | use Phoenix.HTML 41 | import Harmonium 42 | 43 | import ExampleWeb.ErrorHelpers 44 | import ExampleWeb.Gettext 45 | alias ExampleWeb.Router.Helpers, as: Routes 46 | end 47 | end 48 | 49 | def router do 50 | quote do 51 | use Phoenix.Router 52 | import Plug.Conn 53 | import Phoenix.Controller 54 | end 55 | end 56 | 57 | def channel do 58 | quote do 59 | use Phoenix.Channel 60 | import ExampleWeb.Gettext 61 | end 62 | end 63 | 64 | @doc """ 65 | When used, dispatch to the appropriate controller/view/etc. 66 | """ 67 | defmacro __using__(which) when is_atom(which) do 68 | apply(__MODULE__, which, []) 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /example/lib/example_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | # channel "room:*", ExampleWeb.RoomChannel 6 | 7 | # Socket params are passed from the client and can 8 | # be used to verify and authenticate a user. After 9 | # verification, you can put default assigns into 10 | # the socket that will be set for all channels, ie 11 | # 12 | # {:ok, assign(socket, :user_id, verified_user_id)} 13 | # 14 | # To deny connection, return `:error`. 15 | # 16 | # See `Phoenix.Token` documentation for examples in 17 | # performing token verification on connect. 18 | def connect(_params, socket, _connect_info) do 19 | {:ok, socket} 20 | end 21 | 22 | # Socket id's are topics that allow you to identify all sockets for a given user: 23 | # 24 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 25 | # 26 | # Would allow you to broadcast a "disconnect" event and terminate 27 | # all active sockets and channels for a given user: 28 | # 29 | # ExampleWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 30 | # 31 | # Returning `nil` makes this socket anonymous. 32 | def id(_socket), do: nil 33 | end 34 | -------------------------------------------------------------------------------- /example/lib/example_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.PageController do 2 | use ExampleWeb, :controller 3 | 4 | def index(conn, _params) do 5 | render(conn, "index.html") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /example/lib/example_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :example 3 | 4 | socket "/socket", ExampleWeb.UserSocket, 5 | websocket: true, 6 | longpoll: false 7 | 8 | # Serve at "/" the static files from "priv/static" directory. 9 | # 10 | # You should set gzip to true if you are running phx.digest 11 | # when deploying your static files in production. 12 | plug Plug.Static, 13 | at: "/", 14 | from: :example, 15 | gzip: false, 16 | only: ~w(css fonts images js favicon.ico robots.txt) 17 | 18 | # Code reloading can be explicitly enabled under the 19 | # :code_reloader configuration of your endpoint. 20 | if code_reloading? do 21 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 22 | plug Phoenix.LiveReloader 23 | plug Phoenix.CodeReloader 24 | end 25 | 26 | plug Plug.RequestId 27 | plug Plug.Logger 28 | 29 | plug Plug.Parsers, 30 | parsers: [:urlencoded, :multipart, :json], 31 | pass: ["*/*"], 32 | json_decoder: Phoenix.json_library() 33 | 34 | plug Plug.MethodOverride 35 | plug Plug.Head 36 | 37 | # The session will be stored in the cookie and signed, 38 | # this means its contents can be read but not tampered with. 39 | # Set :encryption_salt if you would also like to encrypt it. 40 | plug Plug.Session, 41 | store: :cookie, 42 | key: "_example_key", 43 | signing_salt: "1H/oIyPc" 44 | 45 | plug ExampleWeb.Router 46 | end 47 | -------------------------------------------------------------------------------- /example/lib/example_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import ExampleWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :example 24 | end 25 | -------------------------------------------------------------------------------- /example/lib/example_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.Router do 2 | use ExampleWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_flash 8 | plug :protect_from_forgery 9 | plug :put_secure_browser_headers 10 | end 11 | 12 | pipeline :api do 13 | plug :accepts, ["json"] 14 | end 15 | 16 | scope "/", ExampleWeb do 17 | pipe_through :browser 18 | 19 | get "/", PageController, :index 20 | end 21 | 22 | # Other scopes may use custom stacks. 23 | # scope "/api", ExampleWeb do 24 | # pipe_through :api 25 | # end 26 | end 27 | -------------------------------------------------------------------------------- /example/lib/example_web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Example · Phoenix Framework 8 | "/> 9 | 10 | 11 | <%= render @view_module, @view_template, assigns %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /example/lib/example_web/templates/page/index.html.eex: -------------------------------------------------------------------------------- 1 | <% 2 | 3 | # Some useful data vars that we use in the examples below 4 | 5 | airports = [{"Louis Armstrong", "MSY"}, {"John F. Kennedy", "JFK"}] 6 | 7 | %> 8 | 9 | <%# Styles that are only useful for the styleguide %> 10 | 16 | 17 | <%= row do %> 18 | 19 | <%= col do %> 20 | 21 | <%= row do %> 22 | <%= col do %> 23 |

Styleguide

24 | <% end %> 25 | <% end %> 26 | 27 |
28 | <%= row do %> 29 | <%= col do %> 30 |

Cards

31 | <% end %> 32 | 33 | <%= col do %> 34 | <%= card do %> 35 | <%= card_header do %> 36 | <%= row do %> 37 | <%= col do %>

Header text

<% end %> 38 | <% end %> 39 | <% end %> 40 | <%= card_body do %> 41 | <%= row do %> 42 | <%= col do %>

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

<% end %> 43 | <% end %> 44 | <% end %> 45 | <%= card_footer do %> 46 | <%= row do %> 47 | <%= col do %>Footer text<% end %> 48 | <% end %> 49 | <% end %> 50 | <% end %> 51 | <% end %> 52 | <% end %> 53 |
54 | 55 | 56 |
57 | <%= row do %> 58 | <%= col do %> 59 |

Buttons

60 | 61 | <%= button "button_class(small: true)", to: "#", class: button_class(small: true) %> 62 | <%= button "button_class()", to: "#", class: button_class() %> 63 | <%= button "button_class(large: true)", to: "#", class: button_class(large: true) %> 64 | 65 |

Styles

66 | 67 | <%= button "button_class()", to: "#", class: button_class() %> 68 | <%= button "button_class(secondary: true)", to: "#", class: button_class(secondary: true) %> 69 | <%= button "button_class(tertiary: true)", to: "#", class: button_class(tertiary: true) %> 70 | 71 |
72 | <%= button "button_class(inverted: true)", to: "#", class: button_class(inverted: true) %> 73 | <%= button "button_class(inverted: true, secondary: true)", to: "#", class: button_class(inverted: true, secondary: true) %> 74 |
75 | 76 |

Disabled

77 | 78 | <%= button "button_class(disabled: true)", to: "#", class: button_class(disabled: true) %> 79 | <%= button "button_class(secondary: true, disabled: true)", to: "#", class: button_class(secondary: true, disabled: true) %> 80 | 81 |
82 | <%= button "button_class(inverted: true, disabled: true)", to: "#", class: button_class(inverted: true, disabled: true) %> 83 | <%= button "button_class(inverted: true, secondary: true, disabled: true)", to: "#", class: button_class(inverted: true, secondary: true, disabled: true) %> 84 |
85 | 86 |

button_group do

87 | 88 | <%= button_group do %> 89 | <%= link "Button 1", to: "#", class: button_class(secondary: true) %> 90 | <%= link "Button 2", to: "#", class: button_class() %> 91 | <%= link "Button 3", to: "#", class: button_class(disabled: true) %> 92 | <% end %> 93 | <% end %> 94 | <% end %> 95 |
96 | 97 | 98 |
99 | <%= mock_form fn f -> %> 100 | 101 | <%= row do %> 102 | <%= col do %> 103 |

Form Elements

104 |

105 | Background: 106 | If an input on the first row has an error message, 107 | inputs on the second row will not be properly aligned. 108 |

109 |

110 | Fix: 111 | When there are multiple inputs in a row, 112 | wrap the entire form with the class 113 | AlignInputs. 114 | This can either be applied to a new 115 | <%= "

"%> wrapping the inputs, 116 | or on a row. 117 |

118 | <% end %> 119 | <% end %> 120 | 121 | <%= row do %> 122 | <%= col do %> 123 |

Inputs (with AlignInputs wrapper)

124 | <% end %> 125 | 126 |
127 | <%= col medium: 6 do %> 128 | <%= text_input_stack f, :empty_with_error, label: "Input with Label", input: [placeholder: "Placeholder"] %> 129 | <% end %> 130 | 131 | <%= col medium: 6 do %> 132 | <%= text_input_stack f, :empty, label: "Input with Label", input: [placeholder: "Placeholder"] %> 133 | <% end %> 134 | 135 | <%= col medium: 6 do %> 136 | <%= text_input_stack f, :foo, help: "Help text goes here", input: [placeholder: "Placeholder"] %> 137 | <% end %> 138 | 139 | <%= col medium: 6 do %> 140 | 141 | <% end %> 142 |
143 | <% end %> 144 | 145 | <%= row do %> 146 | <%= col do %> 147 |

File Input

148 | <% end %> 149 | 150 | <%= col medium: 6 do %> 151 | 165 | <% end %> 166 | <% end %> 167 | 168 | <%= row do %> 169 | <%= col do %> 170 |

Input Groups (no helpers yet)

171 | <% end %> 172 | 173 | <%= col do %> 174 | 189 | <% end %> 190 | 191 | <%= col do %> 192 |
193 | $ 194 | 195 |
196 | 197 |
198 |
199 | 200 |
201 |
202 | <% end %> 203 | <% end %> 204 | 205 | <%= row do %> 206 | <%= col do %> 207 |

Selects

208 | <% end %> 209 | 210 | <%= col medium: 6 do %> 211 | <%= select_stack f, :empty, airports, label: "Select" %> 212 | <% end %> 213 | 214 | <%= col medium: 6 do %> 215 | <%= select_stack f, :empty_with_error, airports, label: "Both Help and Error Text", help: "Help text goes here" %> 216 | <% end %> 217 | <% end %> 218 | 219 | <%= row do %> 220 | <%= col do %> 221 |

TextAreas

222 | <% end %> 223 | 224 | <%= col medium: 6 do %> 225 | <%= textarea_stack f, :foo, input: [placeholder: "Textarea"] %> 226 | <% end %> 227 | 228 | <%= col medium: 6 do %> 229 | <%= textarea f, :empty_with_error, placeholder: "Has an error", class: "rev-Textarea is-invalid-input is-invalid" %> 230 | <% end %> 231 | 232 | <%= col medium: 6 do %> 233 | <%= textarea_stack f, :foo, label: "Is a Stack" %> 234 | <% end %> 235 | 236 | <%= col medium: 6 do %> 237 | <%= textarea_stack f, :empty_with_error, label: "Has Help Text and Error", help: "Help text goes here" %> 238 | <% end %> 239 | <% end %> 240 | 241 | <%= row do %> 242 | <%= col medium: 6 do %> 243 | 244 |

Inline Checkboxes

245 | 246 | <%= single_checkbox f, :foo, label: "One" %> 247 | <%= single_checkbox f, :foo, label: "Two" %> 248 | <%= single_checkbox f, :foo, label: "Three" %> 249 | 250 |

Stacked Checkboxes (no helpers yet)

251 | 252 | 256 | 260 | 264 | 265 |
266 | Label for Checkboxes 267 | 271 | 275 | 279 | Help text goes here 280 | Error text goes here 281 |
282 | <% end %> 283 | 284 | <%= col medium: 6 do %> 285 | 286 |

Inline Radio Buttons

287 | 288 | <%= single_radio_button f, :foo, 1, label: "One" %> 289 | <%= single_radio_button f, :foo, 2, label: "Two" %> 290 | <%= single_radio_button f, :foo, 3, label: "Three" %> 291 | 292 |

Stacked Radio Buttons (no helpers yet)

293 | 294 | 298 | 302 | 306 | 307 |
308 | Label for Radios 309 | 313 | 317 | 321 | Help text goes here 322 | Error text goes here 323 |
324 | <% end %> 325 | <% end %> 326 | <% end %> 327 | 328 |
329 | 330 |
331 | <%= row do %> 332 | <%= col do %> 333 |

Table

334 | <% end %> 335 | 336 | <%= col do %> 337 | <%= table do %> 338 | <%= table_head do %> 339 | <%= table_row do %> 340 | <%= table_header do: "ID" %> 341 | <%= table_header do: "First Name" %> 342 | <%= table_header do: "Last Name" %> 343 | <% end %> 344 | <% end %> 345 | <%= table_body do %> 346 | <%= for n <- 0..9 do %> 347 | <%= table_row do %> 348 | <%= table_data do: n %> 349 | <%= table_data do: "John" %> 350 | <%= table_data do: "Doe#{n}" %> 351 | <% end %> 352 | <% end %> 353 | <%= table_row do %> 354 | <%= table_data do: "10" %> 355 | <%= table_data do: "Jane" %> 356 | <%= table_data do: "Doe" %> 357 | <% end %> 358 | <% end %> 359 | <% end %> 360 | <% end %> 361 | <% end %> 362 |
363 | <% end %> 364 | <% end %> 365 | -------------------------------------------------------------------------------- /example/lib/example_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 13 | content_tag(:span, translate_error(error), class: "help-block") 14 | end) 15 | end 16 | 17 | @doc """ 18 | Translates an error message using gettext. 19 | """ 20 | def translate_error({msg, opts}) do 21 | # When using gettext, we typically pass the strings we want 22 | # to translate as a static argument: 23 | # 24 | # # Translate "is invalid" in the "errors" domain 25 | # dgettext("errors", "is invalid") 26 | # 27 | # # Translate the number of files with plural rules 28 | # dngettext("errors", "1 file", "%{count} files", count) 29 | # 30 | # Because the error messages we show in our forms and APIs 31 | # are defined inside Ecto, we need to translate them dynamically. 32 | # This requires us to call the Gettext module passing our gettext 33 | # backend as first argument. 34 | # 35 | # Note we use the "errors" domain, which means translations 36 | # should be written to the errors.po file. The :count option is 37 | # set by Ecto and indicates we should also apply plural rules. 38 | if count = opts[:count] do 39 | Gettext.dngettext(ExampleWeb.Gettext, "errors", msg, msg, count, opts) 40 | else 41 | Gettext.dgettext(ExampleWeb.Gettext, "errors", msg, opts) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /example/lib/example_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.ErrorView do 2 | use ExampleWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.html", _assigns) do 7 | # "Internal Server Error" 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.html" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | Phoenix.Controller.status_message_from_template(template) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /example/lib/example_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.LayoutView do 2 | use ExampleWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /example/lib/example_web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.PageView do 2 | use ExampleWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /example/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Example.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :example, 7 | version: "0.1.0", 8 | elixir: "~> 1.5", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: [:phoenix, :gettext] ++ Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps() 13 | ] 14 | end 15 | 16 | # Configuration for the OTP application. 17 | # 18 | # Type `mix help compile.app` for more information. 19 | def application do 20 | [ 21 | mod: {Example.Application, []}, 22 | extra_applications: [:logger, :runtime_tools] 23 | ] 24 | end 25 | 26 | # Specifies which paths to compile per environment. 27 | defp elixirc_paths(:test), do: ["lib", "test/support"] 28 | defp elixirc_paths(_), do: ["lib"] 29 | 30 | # Specifies your project dependencies. 31 | # 32 | # Type `mix help deps` for examples and options. 33 | defp deps do 34 | [ 35 | {:phoenix, "~> 1.4.0"}, 36 | {:phoenix_pubsub, "~> 1.1"}, 37 | {:phoenix_html, "~> 2.11"}, 38 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 39 | {:gettext, "~> 0.11"}, 40 | {:jason, "~> 1.0"}, 41 | {:plug_cowboy, "~> 2.0"}, 42 | {:phoenix_ecto, "~> 4.0"}, 43 | {:harmonium, path: ".."} 44 | ] 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /example/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "cowboy": {:hex, :cowboy, "2.5.0", "4ef3ae066ee10fe01ea3272edc8f024347a0d3eb95f6fbb9aed556dacbfc1337", [:rebar3], [{:cowlib, "~> 2.6.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.6.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, 3 | "cowlib": {:hex, :cowlib, "2.6.0", "8aa629f81a0fc189f261dc98a42243fa842625feea3c7ec56c48f4ccdb55490f", [:rebar3], [], "hexpm"}, 4 | "decimal": {:hex, :decimal, "1.5.0", "b0433a36d0e2430e3d50291b1c65f53c37d56f83665b43d79963684865beab68", [:mix], [], "hexpm"}, 5 | "ecto": {:hex, :ecto, "3.0.1", "a26605ee7b243a754e6609d1c23da27bcb22823659b07bf03f9020da92a8e4f4", [:mix], [{:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, 6 | "file_system": {:hex, :file_system, "0.2.6", "fd4dc3af89b9ab1dc8ccbcc214a0e60c41f34be251d9307920748a14bf41f1d3", [:mix], [], "hexpm"}, 7 | "gettext": {:hex, :gettext, "0.16.1", "e2130b25eebcbe02bb343b119a07ae2c7e28bd4b146c4a154da2ffb2b3507af2", [:mix], [], "hexpm"}, 8 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 9 | "mime": {:hex, :mime, "1.3.0", "5e8d45a39e95c650900d03f897fbf99ae04f60ab1daa4a34c7a20a5151b7a5fe", [:mix], [], "hexpm"}, 10 | "phoenix": {:hex, :phoenix, "1.4.0", "56fe9a809e0e735f3e3b9b31c1b749d4b436e466d8da627b8d82f90eaae714d2", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"}, 11 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 12 | "phoenix_html": {:hex, :phoenix_html, "2.12.0", "1fb3c2e48b4b66d75564d8d63df6d53655469216d6b553e7e14ced2b46f97622", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 13 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.2.0", "3bb31a9fbd40ffe8652e60c8660dffd72dd231efcdf49b744fb75b9ef7db5dd2", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm"}, 14 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.1", "6668d787e602981f24f17a5fbb69cc98f8ab085114ebfac6cc36e10a90c8e93c", [:mix], [], "hexpm"}, 15 | "plug": {:hex, :plug, "1.7.1", "8516d565fb84a6a8b2ca722e74e2cd25ca0fc9d64f364ec9dbec09d33eb78ccd", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"}, 16 | "plug_cowboy": {:hex, :plug_cowboy, "2.0.0", "ab0c92728f2ba43c544cce85f0f220d8d30fc0c90eaa1e6203683ab039655062", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, 17 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, 18 | "ranch": {:hex, :ranch, "1.6.2", "6db93c78f411ee033dbb18ba8234c5574883acb9a75af0fb90a9b82ea46afa00", [:rebar3], [], "hexpm"}, 19 | } 20 | -------------------------------------------------------------------------------- /example/priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | -------------------------------------------------------------------------------- /example/priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | -------------------------------------------------------------------------------- /example/test/example_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.PageControllerTest do 2 | use ExampleWeb.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, "/") 6 | assert html_response(conn, 200) =~ "Welcome to Phoenix!" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /example/test/example_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.ErrorViewTest do 2 | use ExampleWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(ExampleWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(ExampleWeb.ErrorView, "500.html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /example/test/example_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.LayoutViewTest do 2 | use ExampleWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /example/test/example_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.PageViewTest do 2 | use ExampleWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /example/test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` 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 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with channels 21 | use Phoenix.ChannelTest 22 | 23 | # The default endpoint for testing 24 | @endpoint ExampleWeb.Endpoint 25 | end 26 | end 27 | 28 | setup _tags do 29 | :ok 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /example/test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ExampleWeb.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 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | use Phoenix.ConnTest 22 | alias ExampleWeb.Router.Helpers, as: Routes 23 | 24 | # The default endpoint for testing 25 | @endpoint ExampleWeb.Endpoint 26 | end 27 | end 28 | 29 | setup _tags do 30 | {:ok, conn: Phoenix.ConnTest.build_conn()} 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /example/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /lib/harmonium.ex: -------------------------------------------------------------------------------- 1 | defmodule Harmonium do 2 | @moduledoc """ 3 | Styled HTML grid helpers, form helpers, and more. 4 | """ 5 | 6 | import Phoenix.HTML 7 | import Phoenix.HTML.Form 8 | import Phoenix.HTML.Tag 9 | 10 | @row_class "rev-Row" 11 | @col_class "rev-Col" 12 | @callout_class "rev-Callout" 13 | @menu_class "rev-Menu" 14 | @menu_item_class "rev-Menu-item" 15 | @label_class "rev-InputLabel" 16 | @label_text_class "rev-LabelText" 17 | @input_class "rev-Input" 18 | @textarea_class "rev-Textarea" 19 | @select_class "rev-Select" 20 | @error_class "rev-InputErrors" 21 | @invalid_class "is-invalid" 22 | @help_class "rev-HelpText rev-InputHelpText" 23 | @input_stack_class "rev-InputStack" 24 | @textarea_stack_class "rev-TextareaStack" 25 | @select_stack_class "rev-SelectStack" 26 | @button_class "rev-Button" 27 | @button_group_class "rev-ButtonGroup" 28 | @card_class "rev-Card" 29 | @card_header_class "#{@card_class}-header" 30 | @card_body_class "#{@card_class}-body" 31 | @card_footer_class "#{@card_class}-footer" 32 | @drawer_class "rev-Drawer" 33 | @drawer_class_when_open "#{@drawer_class}--open" 34 | @drawer_closer_class "#{@drawer_class}-closer" 35 | @drawer_expander_class "#{@drawer_class}-expander" 36 | @drawer_contents_class "#{@drawer_class}-contents" 37 | @icon_class "rev-Icon" 38 | 39 | @doc """ 40 | Constructs a CSS class with SUIT modifiers. 41 | 42 | With no `modifiers`, it simply returns the `base_class`. 43 | 44 | iex> rev_class(:Column, []) 45 | "Column" 46 | 47 | For a boolean modifier `{key, value}`, it modifies the class with `key` only if `value` is true. 48 | 49 | iex> rev_class(:Column, shrink: true) 50 | "Column Column--shrink" 51 | iex> rev_class(:Column, shrink: false) 52 | "Column" 53 | 54 | For a non-boolean modifier `{key, value}`, it concatenates key and value. 55 | 56 | iex> rev_class(:Column, medium: 4, large: 3, justify: :Left) 57 | "Column Column--medium4 Column--large3 Column--justifyLeft" 58 | 59 | For the special-case `{:class, value}`, it skips SUIT conventions and adds `value` directly 60 | 61 | iex> rev_class(:Column, medium: 4, class: "more css classes") 62 | "Column Column--medium4 more css classes" 63 | """ 64 | 65 | def rev_class(base_class, nil), do: rev_class(base_class, []) 66 | 67 | def rev_class(base_class, modifiers) when is_atom(base_class), 68 | do: rev_class("#{base_class}", modifiers) 69 | 70 | def rev_class(base_class, modifiers) when is_bitstring(base_class) do 71 | modifiers 72 | |> Enum.reduce(base_class, fn {modifier, value}, class -> 73 | case modifier do 74 | :class -> 75 | "#{class} #{value}" 76 | 77 | _ -> 78 | case value do 79 | false -> class 80 | true -> "#{class} #{base_class}--#{modifier}" 81 | other -> "#{class} #{base_class}--#{modifier}#{other}" 82 | end 83 | end 84 | end) 85 | end 86 | 87 | @doc """ 88 | Constructs a grid row class, following the modifier rules of `rev_class/2`. 89 | 90 | iex> row_class() 91 | "#{@row_class}" 92 | iex> row_class(flex: true) 93 | "#{@row_class} #{@row_class}--flex" 94 | """ 95 | def row_class(modifiers \\ []), do: rev_class(@row_class, modifiers) 96 | 97 | @doc """ 98 | Renders grid row. 99 | 100 | iex> row(do: "hello") |> safe_to_string() 101 | "
hello
" 102 | iex> row(flex: true) do "hello" end |> safe_to_string() 103 | "
hello
" 104 | iex> row(:section, flex: true) do "hello" end |> safe_to_string() 105 | "
hello
" 106 | """ 107 | def row(do: block), do: row([], do: block) 108 | 109 | def row(modifiers, do: block), do: row(:div, modifiers, do: block) 110 | 111 | def row(tag, modifiers, do: block) do 112 | content_tag tag, class: row_class(modifiers) do 113 | block 114 | end 115 | end 116 | 117 | @doc """ 118 | Constructs a grid column class. (Same usage as `row_class/1`.) 119 | """ 120 | def col_class(modifiers \\ []), do: rev_class(@col_class, modifiers) 121 | 122 | @doc """ 123 | Renders a grid column. (Same usage as `rev_row/1`, `rev_row/2`, `rev_row/3`.) 124 | """ 125 | def col(do: block), do: col([], do: block) 126 | 127 | def col(modifiers, do: block), do: col(:div, modifiers, do: block) 128 | 129 | def col(tag, modifiers, do: block) do 130 | content_tag tag, class: col_class(modifiers) do 131 | block 132 | end 133 | end 134 | 135 | @doc """ 136 | Constructs a callout class. 137 | """ 138 | def callout_class(modifiers \\ []), do: rev_class(@callout_class, modifiers) 139 | 140 | @doc """ 141 | Renders a callout. 142 | """ 143 | def callout(do: block), do: callout([], do: block) 144 | 145 | def callout(modifiers, do: block), do: callout(:div, modifiers, do: block) 146 | 147 | def callout(tag, modifiers, do: block) do 148 | content_tag tag, class: callout_class(modifiers) do 149 | block 150 | end 151 | end 152 | 153 | @doc """ 154 | Constructs a menu class. 155 | """ 156 | def menu_class(modifiers \\ []), do: rev_class(@menu_class, modifiers) 157 | 158 | @doc """ 159 | Renders a menu. 160 | """ 161 | def menu(do: block), do: menu([], do: block) 162 | 163 | def menu(modifiers, do: block), do: menu(:div, modifiers, do: block) 164 | 165 | def menu(tag, modifiers, do: block) do 166 | content_tag tag, class: menu_class(modifiers) do 167 | block 168 | end 169 | end 170 | 171 | @doc """ 172 | Constructs a menu item class. 173 | """ 174 | 175 | def menu_item_class(modifiers \\ []), do: rev_class(@menu_item_class, modifiers) 176 | 177 | @doc """ 178 | Renders a rev-Menu-item. 179 | """ 180 | def menu_item(do: block), do: menu_item([], do: block) 181 | 182 | def menu_item(modifiers, do: block), do: menu_item(:div, modifiers, do: block) 183 | 184 | def menu_item(tag, modifiers, do: block) do 185 | content_tag tag, class: menu_item_class(modifiers) do 186 | block 187 | end 188 | end 189 | 190 | @doc """ 191 | Constructs an input label class. 192 | """ 193 | def rev_label_class(modifiers \\ []), do: rev_class(@label_class, modifiers) 194 | 195 | @doc """ 196 | Renders an input label. 197 | """ 198 | def rev_label(modifiers, do: block) do 199 | content_tag :label, class: rev_label_class(modifiers) do 200 | block 201 | end 202 | end 203 | 204 | @doc """ 205 | Renders an input label with a for attribute. 206 | """ 207 | def rev_label(f, key, modifiers, do: block) do 208 | label f, key, class: rev_label_class(modifiers) do 209 | block 210 | end 211 | end 212 | 213 | @doc """ 214 | Renders an input label inner ``. 215 | """ 216 | def rev_label_text(do: block) do 217 | content_tag :span, class: @label_text_class do 218 | block 219 | end 220 | end 221 | 222 | @doc """ 223 | Renders an input error ``. 224 | """ 225 | def rev_error_text(do: block) do 226 | content_tag :small, class: @error_class do 227 | block 228 | end 229 | end 230 | 231 | @doc """ 232 | Renders an input help ``. 233 | """ 234 | def rev_help_text(do: block) do 235 | content_tag :small, class: @help_class do 236 | block 237 | end 238 | end 239 | 240 | # Get the error helper function from the host application 241 | # for translating and interpolating error messages. 242 | # 243 | # Returns the text of the error message if the helper isn't configured. 244 | defp error_helper(error) do 245 | case Application.get_env(:harmonium, :error_helper, nil) do 246 | nil -> 247 | {message, _opts} = error 248 | message 249 | 250 | {module, function} -> apply(module, function, [error]) 251 | 252 | # this version was intended to take a function capture 253 | # like `MyAppWeb.ErrorHelpers.translate_error/1`, 254 | # but the tuple version is now preferred because 255 | # function captures in config.exs break the release process 256 | error_helper -> 257 | error_helper.(error) 258 | end 259 | end 260 | 261 | # Extract an error for a given key from Phoenix form data. 262 | defp extract_error(form, field) do 263 | case List.keyfind(form.errors, field, 0) do 264 | nil -> nil 265 | {_key, error} when is_tuple(error) -> error_helper(error) 266 | {_key, error} when is_bitstring(error) -> error_helper({error, []}) 267 | end 268 | end 269 | 270 | # For building very typical Harmonium form input stacks. 271 | # See `text_input_stack/3` and `select_stack/4` for example usage. 272 | # For other, less uniform input types, you may have to write something more custom. 273 | # In those cases, this function's code can still be a good reference example. 274 | defp input_stack(func, input_class, stack_class, f, key, options, value_options \\ nil) do 275 | error = extract_error(f, key) 276 | 277 | validity_class = if error, do: @invalid_class, else: "" 278 | 279 | label_text = 280 | case options[:label] do 281 | nil -> nil 282 | value -> rev_label_text(do: value) 283 | end 284 | 285 | input_options = 286 | options 287 | |> Keyword.get(:input, []) 288 | |> Keyword.put(:class, "#{input_class} #{validity_class}") 289 | 290 | required? = Keyword.get(input_options, :required, false) 291 | required_class = if required?, do: " is-required", else: "" 292 | 293 | input = 294 | case value_options do 295 | nil -> func.(f, key, input_options) 296 | _ -> func.(f, key, value_options, input_options) 297 | end 298 | 299 | error_text = 300 | case error do 301 | nil -> nil 302 | _ -> rev_error_text(do: error) 303 | end 304 | 305 | help_text = 306 | case options[:help] do 307 | nil -> nil 308 | value -> rev_help_text(do: value) 309 | end 310 | 311 | rev_label class: "#{stack_class} #{validity_class}#{required_class}" do 312 | ~E""" 313 | <%= label_text %> 314 | <%= input %> 315 | <%= error_text %> 316 | <%= help_text %> 317 | """ 318 | end 319 | end 320 | 321 | @doc """ 322 | Harmonium provides a number of shortcuts for building richly-styled form inputs. 323 | We refer to these as "stacks." 324 | A stack is a label wrapper around an input with optional label text, help text, and error display. 325 | Under the covers, these stack helpers uses the input helpers from `Phoenix.HTML.Form`. 326 | 327 | This particular function renders a text input stack. 328 | 329 | iex> text_input_stack(f, :required_string) |> safe_to_string() 330 | "" 331 | 332 | You may optionally add a label. 333 | 334 | iex> text_input_stack(f, :required_string, label: "Required String") |> safe_to_string() 335 | "" 336 | 337 | You may optionally add help. 338 | 339 | iex> text_input_stack(f, :required_string, help: "This field is required.") |> safe_to_string() 340 | "" 341 | 342 | The `:input` option passes another set of options through to the `` field. 343 | 344 | iex> text_input_stack(f, :required_string, input: [placeholder: "Required String"]) |> safe_to_string() 345 | "" 346 | 347 | When the field has an error, error styles are applied, and it is displayed. 348 | iex> Application.put_env(:harmonium, :error_helper, nil) 349 | iex> text_input_stack(form_with_errors, :required_string) |> safe_to_string() 350 | "" 351 | 352 | If an error helper from the host application is configured, it is used to format errors. (TUPLE FORMAT) 353 | iex> Application.put_env(:harmonium, :error_helper, {HarmoniumTest, :mock_translate_error}) 354 | iex> text_input_stack(form_with_errors, :required_string) |> safe_to_string() 355 | "" 356 | 357 | If an error helper from the host application is configured, it is used to format errors. (DEPRECATED FUNCTION CAPTURE FORMAT) 358 | iex> Application.put_env(:harmonium, :error_helper, &mock_translate_error/1) 359 | iex> text_input_stack(form_with_errors, :required_string) |> safe_to_string() 360 | "" 361 | """ 362 | def text_input_stack(f, key, options \\ []) do 363 | input_stack(&text_input/3, @input_class, @input_stack_class, f, key, options) 364 | end 365 | 366 | @doc """ 367 | See `text_input_stack/3`. 368 | """ 369 | def password_input_stack(f, key, options \\ []) do 370 | input_stack(&password_input/3, @input_class, @input_stack_class, f, key, options) 371 | end 372 | 373 | @doc """ 374 | Renders a textarea stack. 375 | 376 | iex> textarea_stack(f, :required_string) |> safe_to_string() 377 | "" 378 | 379 | See `text_input_stack/3` for more options. 380 | """ 381 | def textarea_stack(f, key, options \\ []) do 382 | input_stack(&textarea/3, @textarea_class, @textarea_stack_class, f, key, options) 383 | end 384 | 385 | @doc """ 386 | Renders an email input stack. 387 | 388 | iex> text_input_stack(f, :required_string) |> safe_to_string() 389 | "" 390 | 391 | See `text_input_stack/3` for more options. 392 | """ 393 | def email_input_stack(f, key, options \\ []) do 394 | input_stack(&email_input/3, @input_class, @input_stack_class, f, key, options) 395 | end 396 | 397 | @doc """ 398 | Renders a number input stack. 399 | 400 | iex> text_input_stack(f, :required_string) |> safe_to_string() 401 | "" 402 | 403 | See `text_input_stack/3` for more options. 404 | """ 405 | def number_input_stack(f, key, options \\ []) do 406 | input_stack(&number_input/3, @input_class, @input_stack_class, f, key, options) 407 | end 408 | 409 | @doc """ 410 | Renders a phone input stack. 411 | 412 | iex> phone_input_stack(f, :required_string) |> safe_to_string() 413 | "" 414 | 415 | See `text_input_stack/3` for more options. 416 | """ 417 | 418 | def phone_input_stack(f, key, options \\ []) do 419 | input_stack(&telephone_input/3, @input_class, @input_stack_class, f, key, options) 420 | end 421 | 422 | @doc """ 423 | Similar usage to `text_input_stack/3`, but it also passes `value_options` through to `Phoenix.HTML.Form.select/4`. 424 | 425 | iex> select_stack(f, :required_string, ["Hi": "hi", "Hello": "hello"]) |> safe_to_string() 426 | "" 427 | 428 | iex> select_stack(f, :required_string, ["Hi": "hi", "Hello": "hello"], input: [value: "hello"]) |> safe_to_string() 429 | "" 430 | 431 | See `text_input_stack/3` for more options. 432 | """ 433 | def select_stack(f, key, value_options, options \\ []) do 434 | input_stack(&select/4, @select_class, @select_stack_class, f, key, options, value_options) 435 | end 436 | 437 | @doc """ 438 | Same as `select_stack/4`, but renders the input as a `\\n \\n \\n" 442 | 443 | iex> multiple_select_stack(f, :required_string, ["Hi": "hi", "Hello": "hello"], input: [value: ["hi", "hello"]]) |> safe_to_string() 444 | "" 445 | 446 | See `text_input_stack/3` for more options. 447 | """ 448 | def multiple_select_stack(f, key, value_options, options \\ []) do 449 | input_stack( 450 | &multiple_select/4, 451 | @select_class, 452 | @select_stack_class, 453 | f, 454 | key, 455 | options, 456 | value_options 457 | ) 458 | end 459 | 460 | @doc """ 461 | Render a single checkbox. 462 | 463 | iex> single_checkbox(f, :bool) |> safe_to_string() 464 | "" 465 | 466 | You may optionally add a label: 467 | 468 | iex> single_checkbox(f, :bool, label: "Publish?") |> safe_to_string() 469 | "" 470 | """ 471 | def single_checkbox(f, key, options \\ []) do 472 | input_options = 473 | options 474 | |> Keyword.get(:input, []) 475 | |> Keyword.put(:class, "rev-Checkbox-input") 476 | 477 | box = checkbox(f, key, input_options) 478 | 479 | label = 480 | case options[:label] do 481 | nil -> 482 | nil 483 | 484 | text -> 485 | content_tag :span, class: "rev-Checkbox-label" do 486 | text 487 | end 488 | end 489 | 490 | rev_label f, key, class: "rev-Checkbox" do 491 | ~E"<%= box %><%= label %>" 492 | end 493 | end 494 | 495 | @doc """ 496 | Render a single radio button. 497 | 498 | iex> single_radio_button(f, :required_string, "one") |> safe_to_string() 499 | "" 500 | 501 | You may optionally add a label: 502 | 503 | iex> single_radio_button(f, :required_string, "one", label: "Option One") |> safe_to_string() 504 | "" 505 | """ 506 | def single_radio_button(f, key, value, options \\ []) do 507 | input_options = 508 | options 509 | |> Keyword.get(:input, []) 510 | |> Keyword.put(:class, "rev-Radio-input") 511 | 512 | box = radio_button(f, key, value, input_options) 513 | 514 | label = 515 | case options[:label] do 516 | nil -> 517 | nil 518 | 519 | text -> 520 | content_tag :span, class: "rev-Radio-label" do 521 | text 522 | end 523 | end 524 | 525 | rev_label class: "rev-Radio" do 526 | ~E"<%= box %><%= label %>" 527 | end 528 | end 529 | 530 | def button_class(modifiers \\ []), do: rev_class(@button_class, modifiers) 531 | 532 | def button_group_class(modifiers \\ []), do: rev_class(@button_group_class, modifiers) 533 | 534 | def button_group(do: block), do: button_group([], do: block) 535 | 536 | def button_group(modifiers, do: block) do 537 | content_tag :div, class: button_group_class(modifiers) do 538 | block 539 | end 540 | end 541 | 542 | def card_class(modifiers \\ []), do: rev_class(@card_class, modifiers) 543 | def card_header_class(modifiers \\ []), do: rev_class(@card_header_class, modifiers) 544 | def card_body_class(modifiers \\ []), do: rev_class(@card_body_class, modifiers) 545 | def card_footer_class(modifiers \\ []), do: rev_class(@card_footer_class, modifiers) 546 | 547 | def card(do: block), do: card([], do: block) 548 | 549 | def card(modifiers, do: block), do: card(:div, modifiers, do: block) 550 | 551 | def card(tag, modifiers, do: block) do 552 | content_tag tag, class: card_class(modifiers) do 553 | block 554 | end 555 | end 556 | 557 | def card_header(do: block), do: card_header([], do: block) 558 | 559 | def card_header(modifiers, do: block), do: card_header(:div, modifiers, do: block) 560 | 561 | def card_header(tag, modifiers, do: block) do 562 | content_tag tag, class: card_header_class(modifiers) do 563 | block 564 | end 565 | end 566 | 567 | def card_body(do: block), do: card_body([], do: block) 568 | 569 | def card_body(modifiers, do: block), do: card_body(:div, modifiers, do: block) 570 | 571 | def card_body(tag, modifiers, do: block) do 572 | content_tag tag, class: card_body_class(modifiers) do 573 | block 574 | end 575 | end 576 | 577 | def card_footer(do: block), do: card_footer([], do: block) 578 | 579 | def card_footer(modifiers, do: block), do: card_footer(:div, modifiers, do: block) 580 | 581 | def card_footer(tag, modifiers, do: block) do 582 | content_tag tag, class: card_footer_class(modifiers) do 583 | block 584 | end 585 | end 586 | 587 | @doc """ 588 | Outputs CSS class names for an icon. 589 | 590 | iex> icon_class(:star) 591 | "rev-Icon rev-Icon--star" 592 | iex> icon_class("star-hollow") 593 | "rev-Icon rev-Icon--star-hollow" 594 | """ 595 | 596 | def icon_class(icon_name) when is_atom(icon_name) or is_bitstring(icon_name), 597 | do: rev_class(@icon_class, "#{icon_name}": true) 598 | 599 | @doc """ 600 | Renders an icon tag. 601 | 602 | iex> icon("up-arrow") |> safe_to_string() 603 | "" 604 | 605 | You can override the tag type. 606 | 607 | iex> icon(:i, "up-arrow") |> safe_to_string() 608 | "" 609 | 610 | You can add additional classes and pass other attributes to the tag. 611 | 612 | iex> icon("up-arrow", class: "ExtraClass", title: "An Icon") |> safe_to_string() 613 | "" 614 | 615 | You can do all of these things at once. 616 | 617 | iex> icon(:i, "up-arrow", class: "ExtraClass", title: "An Icon") |> safe_to_string() 618 | "" 619 | 620 | """ 621 | 622 | def icon(icon_name) when is_atom(icon_name) or is_bitstring(icon_name), 623 | do: icon(:span, icon_name) 624 | 625 | def icon(tag, icon_name) when is_atom(icon_name) or is_bitstring(icon_name), 626 | do: icon(tag, icon_name, []) 627 | 628 | def icon(icon_name, options) 629 | when (is_atom(icon_name) or is_bitstring(icon_name)) and is_list(options), 630 | do: icon(:span, icon_name, options) 631 | 632 | def icon(tag, icon_name, options) 633 | when (is_atom(icon_name) or is_bitstring(icon_name)) and is_list(options) do 634 | iclass = icon_class(icon_name) 635 | 636 | tag_options = 637 | options 638 | |> Keyword.update(:class, iclass, fn x -> "#{iclass} #{x}" end) 639 | 640 | content_tag tag, tag_options do 641 | nil 642 | end 643 | end 644 | 645 | def rev_data(attrs) do 646 | [{"init", true} | attrs] 647 | |> Enum.map(fn 648 | {key, value} -> {"rev-#{key}", value} 649 | key -> "rev-#{key}" 650 | end) 651 | end 652 | 653 | def drawer_class(modifiers \\ []), do: rev_class(@drawer_class, modifiers) 654 | def drawer_closer_class(modifiers \\ []), do: rev_class(@drawer_closer_class, modifiers) 655 | def drawer_expander_class(modifiers \\ []), do: rev_class(@drawer_expander_class, modifiers) 656 | def drawer_contents_class(modifiers \\ []), do: rev_class(@drawer_contents_class, modifiers) 657 | 658 | def drawer(do: block), do: drawer([], do: block) 659 | def drawer(modifiers, do: block), do: drawer(:div, modifiers, do: block) 660 | 661 | def drawer(tag, modifiers, do: block) do 662 | {expander_content, modifiers} = Keyword.pop(modifiers, :expander, icon("open")) 663 | {closer_content, modifiers} = Keyword.pop(modifiers, :closer, icon("close")) 664 | 665 | expander = 666 | content_tag :a, 667 | class: drawer_expander_class(), 668 | data: rev_data(parent: @drawer_class, add_class: @drawer_class_when_open) do 669 | expander_content 670 | end 671 | 672 | closer = 673 | content_tag :a, 674 | class: drawer_closer_class(), 675 | data: rev_data(parent: @drawer_class, remove_class: @drawer_class_when_open) do 676 | closer_content 677 | end 678 | 679 | contents = 680 | content_tag :div, class: drawer_contents_class() do 681 | ~E""" 682 | <%= closer %> 683 | <%= block %> 684 | """ 685 | end 686 | 687 | content_tag tag, class: drawer_class(modifiers) do 688 | ~E""" 689 | <%= expander %> 690 | <%= contents %> 691 | """ 692 | end 693 | end 694 | 695 | defdelegate table_class(modifiers \\ []), to: Harmonium.Table 696 | 697 | defdelegate table(modifiers \\ []), to: Harmonium.Table 698 | 699 | defdelegate table_head_class(modifiers \\ []), to: Harmonium.Table 700 | 701 | defdelegate table_head(opts \\ []), to: Harmonium.Table 702 | 703 | defdelegate table_body_class(modifiers \\ []), to: Harmonium.Table 704 | 705 | defdelegate table_body(opts \\ []), to: Harmonium.Table 706 | 707 | defdelegate table_row_class(modifiers \\ []), to: Harmonium.Table 708 | 709 | defdelegate table_row(opts \\ []), to: Harmonium.Table 710 | 711 | defdelegate table_header_class(modifiers \\ []), to: Harmonium.Table 712 | 713 | defdelegate table_header(opts \\ []), to: Harmonium.Table 714 | 715 | defdelegate table_data_class(modifiers \\ []), to: Harmonium.Table 716 | 717 | defdelegate table_data(opts \\ []), to: Harmonium.Table 718 | 719 | @mock_form_default_form %Phoenix.HTML.Form{data: %{}, errors: [], name: "mock", id: "mock"} 720 | @mock_form_default_inputs %{ 721 | empty: nil, 722 | string: "Hello, World!", 723 | int: 42, 724 | float: 3.14, 725 | boolean: true, 726 | datetime: DateTime.utc_now() 727 | } 728 | @mock_form_default_error "There was an error." 729 | 730 | @doc """ 731 | Utility function for mocking a form input. 732 | It adds two inputs: 1) `:key`, and 2) `:key_with_error` (which will have an attached error) 733 | 734 | iex> f = %Phoenix.HTML.Form{data: %{}, errors: []} 735 | iex> mocked = mock_form_input(f, :foo, "foo value", "oops") 736 | iex> mocked.data 737 | %{foo: "foo value", foo_with_error: "foo value"} 738 | iex> mocked.errors 739 | [foo_with_error: "oops"] 740 | """ 741 | 742 | def mock_form_input( 743 | %Phoenix.HTML.Form{data: data, errors: errors} = form, 744 | key, 745 | value, 746 | error_string \\ @mock_form_default_error 747 | ) do 748 | key_with_error = :"#{key}_with_error" 749 | 750 | data = 751 | data 752 | |> Map.put(key, value) 753 | |> Map.put(key_with_error, value) 754 | 755 | errors = 756 | errors 757 | |> Keyword.put(key_with_error, error_string) 758 | 759 | %{form | data: data, errors: errors} 760 | end 761 | 762 | @doc """ 763 | Utility function for mocking forms when you do not yet have the underlying data structure. 764 | 765 | <%= mock_form fn f -> %> 766 | <%= row do %> 767 | <%= col do text_input_stack f, :string end %> 768 | <%= col do text_input_stack f, :string, label: "Hello Label" end %> 769 | <%= col do text_input_stack f, :string, label: "Hello Label", help: "This is help text." end %> 770 | <%= col do text_input_stack f, :string_with_error end %> 771 | <%= col do text_input_stack f, :string_with_error, help: "Help text and errors stack." end %> 772 | <%= col do text_input_stack f, :nil, input: [placeholder: "Empty placeholder"] end %> 773 | <% end %> 774 | <% end %> 775 | 776 | With `mock_form/1`, you get a default set of inputs to work with: 777 | 778 | iex> mock_form(fn f -> Map.keys(f.data) end) 779 | [ 780 | :boolean, 781 | :boolean_with_error, 782 | :datetime, 783 | :datetime_with_error, 784 | :empty, 785 | :empty_with_error, 786 | :float, 787 | :float_with_error, 788 | :int, 789 | :int_with_error, 790 | :string, 791 | :string_with_error 792 | ] 793 | 794 | As you might expect, the `*_with_error` inputs will have errors attached to them. 795 | 796 | iex> mock_form(fn f -> Keyword.keys(f.errors) end) 797 | [ 798 | :string_with_error, 799 | :int_with_error, 800 | :float_with_error, 801 | :empty_with_error, 802 | :datetime_with_error, 803 | :boolean_with_error 804 | ] 805 | """ 806 | 807 | def mock_form(func), do: mock_form(@mock_form_default_inputs, func) 808 | 809 | @doc """ 810 | With `mock_form/2`, rather than getting default inputs, you supply your own. 811 | Much the same as `mock_form/1` and `mock_form_input/4`, the `*_with_error` inputs have attached errors. 812 | 813 | iex> mock_form(%{foo: "foo value", bar: "bar value"}, &(&1)) 814 | %Phoenix.HTML.Form{ 815 | data: %{ 816 | bar: "bar value", 817 | bar_with_error: "bar value", 818 | foo: "foo value", 819 | foo_with_error: "foo value" 820 | }, 821 | errors: [ 822 | foo_with_error: "There was an error.", 823 | bar_with_error: "There was an error." 824 | ], 825 | hidden: [], 826 | id: "mock", 827 | impl: nil, 828 | index: nil, 829 | name: "mock", 830 | options: [], 831 | params: %{}, 832 | source: nil 833 | } 834 | """ 835 | 836 | def mock_form(inputs, func) do 837 | inputs 838 | |> Enum.reduce(@mock_form_default_form, fn {k, v}, f -> mock_form_input(f, k, v) end) 839 | |> func.() 840 | end 841 | end 842 | -------------------------------------------------------------------------------- /lib/harmonium/table.ex: -------------------------------------------------------------------------------- 1 | defmodule Harmonium.Table do 2 | @moduledoc """ 3 | Adds component to render pagination with correct harmonium classes. 4 | """ 5 | 6 | import Phoenix.HTML.Tag 7 | 8 | @doc """ 9 | Constructs a callout class. 10 | 11 | iex> table_class() 12 | "rev-Table" 13 | 14 | iex> table_class(color: "blue") 15 | "rev-Table rev-Table--colorblue" 16 | 17 | iex> table_class(bold: true) 18 | "rev-Table rev-Table--bold" 19 | """ 20 | def table_class(modifiers \\ []), do: Harmonium.rev_class("rev-Table", modifiers) 21 | 22 | @doc """ 23 | Renders a table element (). 24 | 25 | iex> safe_to_string(table(do: [])) 26 | ~S(
) 27 | """ 28 | def table(opts \\ []), do: table_element(:table, :table_class, opts) 29 | 30 | @doc """ 31 | Constructs a table head class. 32 | 33 | iex> table_head_class() 34 | "rev-Table-head" 35 | 36 | iex> table_head_class(stacked: true) 37 | "rev-Table-head rev-Table-head--stacked" 38 | """ 39 | def table_head_class(modifiers \\ []), do: Harmonium.rev_class("rev-Table-head", modifiers) 40 | 41 | @doc """ 42 | Renders a table head element (). 43 | 44 | iex> safe_to_string(table_head(do: [])) 45 | ~S() 46 | """ 47 | def table_head(opts \\ []), do: table_element(:thead, :table_head_class, opts) 48 | 49 | @doc """ 50 | Constructs a table body class. 51 | 52 | iex> table_body_class() 53 | "rev-Table-body" 54 | 55 | iex> table_body_class(stacked: true) 56 | "rev-Table-body rev-Table-body--stacked" 57 | """ 58 | def table_body_class(modifiers \\ []), do: Harmonium.rev_class("rev-Table-body", modifiers) 59 | 60 | @doc """ 61 | Renders a table body element (). 62 | 63 | iex> safe_to_string(table_body(do: [])) 64 | ~S() 65 | """ 66 | def table_body(opts \\ []), do: table_element(:tbody, :table_body_class, opts) 67 | 68 | @doc """ 69 | Constructs a table row class. 70 | 71 | iex> table_row_class() 72 | "rev-Table-row" 73 | 74 | iex> table_row_class(stacked: true) 75 | "rev-Table-row rev-Table-row--stacked" 76 | """ 77 | def table_row_class(modifiers \\ []), do: Harmonium.rev_class("rev-Table-row", modifiers) 78 | 79 | @doc """ 80 | Renders a table row element (). 81 | 82 | iex> safe_to_string(table_row(do: [])) 83 | ~S() 84 | """ 85 | def table_row(opts \\ []), do: table_element(:tr, :table_row_class, opts) 86 | 87 | @doc """ 88 | Constructs a table header class. 89 | 90 | iex> table_header_class() 91 | "rev-Table-header" 92 | 93 | iex> table_header_class(stacked: true) 94 | "rev-Table-header rev-Table-header--stacked" 95 | """ 96 | def table_header_class(modifiers \\ []), do: Harmonium.rev_class("rev-Table-header", modifiers) 97 | 98 | @doc """ 99 | Renders a table header element (). 100 | 101 | iex> safe_to_string(table_header(do: [])) 102 | ~S() 103 | """ 104 | def table_header(opts \\ []), do: table_element(:th, :table_header_class, opts) 105 | 106 | @doc """ 107 | Constructs a table data class. 108 | 109 | iex> table_data_class() 110 | "rev-Table-data" 111 | 112 | iex> table_data_class(stacked: true) 113 | "rev-Table-data rev-Table-data--stacked" 114 | """ 115 | def table_data_class(modifiers \\ []), do: Harmonium.rev_class("rev-Table-data", modifiers) 116 | 117 | @doc """ 118 | Renders a table data element (). 119 | 120 | iex> safe_to_string(table_data) 121 | ~S() 122 | 123 | iex> safe_to_string(table_data(attrs: [colspan: 2])) 124 | ~S() 125 | 126 | iex> safe_to_string(table_data(do: [])) 127 | ~S() 128 | 129 | iex> safe_to_string(table_data(bold: true, attrs: [colspan: 2], do: [])) 130 | ~S() 131 | 132 | iex> safe_to_string(table_data(bold: true, italic: true, attrs: [colspan: 2], do: [])) 133 | ~S() 134 | """ 135 | def table_data(opts \\ []), do: table_element(:td, :table_data_class, opts) 136 | 137 | defp table_element(tag, class_function, opts) do 138 | {block, opts} = Keyword.pop(opts, :do, []) 139 | {attrs, modifiers} = Keyword.pop(opts, :attrs, []) 140 | modifier_classes = apply(__MODULE__, class_function, [modifiers]) 141 | opts = Keyword.merge(attrs, [class: modifier_classes]) 142 | 143 | content_tag tag, opts do 144 | block 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Harmonium.MixProject do 2 | use Mix.Project 3 | 4 | @github "https://github.com/revelrylabs/phoenix_harmonium" 5 | 6 | def project do 7 | [ 8 | app: :harmonium, 9 | version: "2.3.0", 10 | elixir: "~> 1.12", 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | package: package(), 14 | test_coverage: [tool: ExCoveralls], 15 | 16 | # Docs 17 | name: "Harmonium", 18 | source_url: @github, 19 | homepage_url: @github, 20 | # The main page in the docs 21 | docs: [main: "readme", extras: ["README.md"]] 22 | ] 23 | end 24 | 25 | # Run "mix help compile.app" to learn about applications. 26 | def application do 27 | [ 28 | extra_applications: [:logger] 29 | ] 30 | end 31 | 32 | # Run "mix help deps" to learn about dependencies. 33 | defp deps do 34 | [ 35 | {:phoenix_html, "~> 3.1"}, 36 | {:phoenix_live_view, "~> 0.16.0"}, 37 | {:excoveralls, "~> 0.10.4", only: :test}, 38 | {:phoenix_ecto, "~> 4.4", only: :test}, 39 | {:ex_doc, "~> 0.20.0", only: :dev} 40 | ] 41 | end 42 | 43 | defp package do 44 | [ 45 | description: "Styled UI component helpers for Phoenix", 46 | files: ["lib", "mix.exs", "README.md", "LICENSE", "CHANGELOG.md"], 47 | maintainers: ["Joel Wietelmann"], 48 | licenses: ["MIT"], 49 | links: %{ 50 | "GitHub" => @github 51 | }, 52 | build_tools: ["mix"] 53 | ] 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, 3 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 4 | "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm", "e3be2bc3ae67781db529b80aa7e7c49904a988596e2dbff897425b48b3581161"}, 5 | "ecto": {:hex, :ecto, "3.7.1", "a20598862351b29f80f285b21ec5297da1181c0442687f9b8329f0445d228892", [:mix], [{:decimal, "~> 1.6 or ~> 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", "d36e5b39fc479e654cffd4dbe1865d9716e4a9b6311faff799b6f90ab81b8638"}, 6 | "ex_doc": {:hex, :ex_doc, "0.20.2", "1bd0dfb0304bade58beb77f20f21ee3558cc3c753743ae0ddbb0fd7ba2912331", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "8e24fc8ff9a50b9f557ff020d6c91a03cded7e59ac3e0eec8a27e771430c7d27"}, 7 | "excoveralls": {:hex, :excoveralls, "0.10.6", "e2b9718c9d8e3ef90bc22278c3f76c850a9f9116faf4ebe9678063310742edc2", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b06c73492aa9940c4c29cfc1356bcf5540ae318f17b423749a0615a66ee3e049"}, 8 | "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "c2790c9f0f7205f4a362512192dee8179097394400e745e4d20bab7226a8eaad"}, 10 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, 11 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 12 | "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"}, 13 | "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5fbc8e549aa9afeea2847c0769e3970537ed302f93a23ac612602e805d9d1e7f"}, 14 | "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "adf0218695e22caeda2820eaba703fa46c91820d53813a2223413da3ef4ba515"}, 15 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 16 | "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, 17 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 18 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm", "5c040b8469c1ff1b10093d3186e2e10dbe483cd73d79ec017993fb3985b8a9b3"}, 19 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 20 | "phoenix": {:hex, :phoenix, "1.6.6", "281c8ce8dccc9f60607346b72cdfc597c3dde134dd9df28dff08282f0b751754", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "807bd646e64cd9dc83db016199715faba72758e6db1de0707eef0a2da4924364"}, 21 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, 22 | "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, 23 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.16.4", "5692edd0bac247a9a816eee7394e32e7a764959c7d0cf9190662fc8b0cd24c97", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.9 or ~> 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "754ba49aa2e8601afd4f151492c93eb72df69b0b9856bab17711b8397e43bba0"}, 24 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, 25 | "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, 26 | "plug": {:hex, :plug, "1.13.4", "addb6e125347226e3b11489e23d22a60f7ab74786befb86c14f94fb5f23ca9a4", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "06114c1f2a334212fe3ae567dbb3b1d29fd492c1a09783d52f3d489c1a6f4cf2"}, 27 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 28 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"}, 29 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm", "603561dc0fd62f4f2ea9b890f4e20e1a0d388746d6e20557cafb1b16950de88c"}, 30 | "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, 31 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, 32 | } 33 | -------------------------------------------------------------------------------- /test/harmonium/table_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Harmonium.TableTest do 2 | use ExUnit.Case 3 | import Phoenix.HTML, only: [safe_to_string: 1] 4 | import Harmonium.Table 5 | 6 | doctest Harmonium.Table 7 | end 8 | -------------------------------------------------------------------------------- /test/harmonium_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Widget do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | embedded_schema do 6 | field(:required_string, :string) 7 | field(:bool, :boolean) 8 | end 9 | 10 | def changeset(%Widget{} = widget, attrs) do 11 | widget 12 | |> cast(attrs, [:required_string, :bool]) 13 | |> validate_required([:required_string]) 14 | end 15 | 16 | def new(attrs), do: changeset(%Widget{}, attrs) 17 | 18 | def validate(%Ecto.Changeset{} = changeset), do: apply_action(changeset, :insert) 19 | end 20 | 21 | defmodule HarmoniumTest do 22 | use ExUnit.Case 23 | import Harmonium 24 | import Phoenix.HTML, only: [safe_to_string: 1] 25 | import Phoenix.HTML.Form, only: [form_for: 3] 26 | 27 | def changeset(attrs \\ %{required_string: "hello"}) do 28 | Widget.new(attrs) 29 | end 30 | 31 | def changeset_with_errors() do 32 | changeset(%{required_string: nil}) 33 | |> Widget.validate() 34 | |> case do 35 | {:error, change} -> change 36 | end 37 | end 38 | 39 | defp to_form(change), do: Phoenix.HTML.FormData.to_form(change, []) 40 | 41 | def f() do 42 | changeset() |> to_form() 43 | end 44 | 45 | def form_with_errors() do 46 | changeset_with_errors() |> to_form() 47 | end 48 | 49 | def mock_translate_error(_) do 50 | "This is a translated or formatted error" 51 | end 52 | 53 | doctest Harmonium 54 | end 55 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------