diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..40fef6c --- /dev/null +++ b/.credo.exs @@ -0,0 +1,216 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "src/", + "test/", + "web/", + "apps/*/lib/", + "apps/*/src/", + "apps/*/test/", + "apps/*/web/" + ], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: false, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: %{ + enabled: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 0]}, + {Credo.Check.Design.TagFIXME, []}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleDoc, false}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.Apply, []}, + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.MapJoin, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + {Credo.Check.Refactor.FilterCount, []}, + {Credo.Check.Refactor.FilterFilter, []}, + {Credo.Check.Refactor.RejectReject, []}, + {Credo.Check.Refactor.RedundantWithClauseResult, []}, + + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.Dbg, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.SpecWithStruct, []}, + {Credo.Check.Warning.WrongTestFileExtension, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.UnsafeExec, []} + ], + disabled: [ + # + # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) + + # + # Controversial and experimental checks (opt-in, just move the check to `:enabled` + # and be sure to use `mix credo --strict` to see low priority checks) + # + {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, + {Credo.Check.Consistency.UnusedVariableNames, false}, + {Credo.Check.Design.DuplicatedCode, false}, + {Credo.Check.Design.SkipTestWithoutComment, []}, + {Credo.Check.Readability.AliasAs, []}, + {Credo.Check.Readability.BlockPipe, []}, + {Credo.Check.Readability.ImplTrue, []}, + {Credo.Check.Readability.MultiAlias, []}, + {Credo.Check.Readability.NestedFunctionCalls, []}, + {Credo.Check.Readability.OneArityFunctionInPipe, []}, + {Credo.Check.Readability.SeparateAliasRequire, []}, + {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, + {Credo.Check.Readability.SinglePipe, []}, + {Credo.Check.Readability.Specs, false}, + {Credo.Check.Readability.StrictModuleLayout, false}, + {Credo.Check.Readability.WithCustomTaggedTuple, []}, + {Credo.Check.Readability.OnePipePerLine, []}, + {Credo.Check.Refactor.ABCSize, false}, + {Credo.Check.Refactor.AppendSingleItem, []}, + {Credo.Check.Refactor.DoubleBooleanNegation, []}, + {Credo.Check.Refactor.FilterReject, []}, + {Credo.Check.Refactor.IoPuts, []}, + {Credo.Check.Refactor.MapMap, []}, + {Credo.Check.Refactor.ModuleDependencies, []}, + {Credo.Check.Refactor.NegatedIsNil, []}, + {Credo.Check.Refactor.PassAsyncInTestCases, []}, + {Credo.Check.Refactor.PipeChainStart, []}, + {Credo.Check.Refactor.RejectFilter, []}, + {Credo.Check.Refactor.VariableRebinding, false}, + {Credo.Check.Warning.LazyLogging, false}, + {Credo.Check.Warning.LeakyEnvironment, []}, + {Credo.Check.Warning.MapGetUnsafePass, []}, + {Credo.Check.Warning.MixEnv, []}, + {Credo.Check.Warning.UnsafeToAtom, []} + + # {Credo.Check.Refactor.MapInto, []}, + + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + } + ] +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9142239 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# editorconfig.org +root = true + +[*] +indent_size = 2 +indent_style = space +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..2eeb5eb --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,12 @@ +# Used by "mix format" +[ + import_deps: [:phoenix], + plugins: [TailwindFormatter, Phoenix.LiveView.HTMLFormatter], + inputs: [ + "{mix,.formatter}.exs", + "{config,lib,test}/**/*.{ex,exs}", + "demo_app/{config,lib,test}/**/*.{ex,exs}", + "installer/{config,lib,test}/**/*.{ex,exs}" + ], + line_length: 150 +] diff --git a/.github/actions/setup-sui-for-ci/action.yml b/.github/actions/setup-sui-for-ci/action.yml new file mode 100644 index 0000000..2ddc665 --- /dev/null +++ b/.github/actions/setup-sui-for-ci/action.yml @@ -0,0 +1,36 @@ +name: Setup Station UI app for CI +runs: + using: "composite" + steps: + - uses: erlef/setup-elixir@v1 + with: + otp-version: ${{ env.OTP_VERSION }} + elixir-version: ${{ env.ELIXIR_VERSION }} + + - uses: actions/cache@v3 + with: + path: deps + key: ${{ runner.os }}-mix-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} + restore-keys: | + ${{ runner.os }}-mix-x + + - name: Retrieve Mix Build Cache + uses: actions/cache@v3 + id: mix-build-cache #id to use in retrieve action + with: + path: | + _build/test/lib + ~/.mix + key: ${{ runner.os }}-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }}-mix-build-${{ github.event.pull_request.base.sha }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} + restore-keys: | + ${{ runner.os }}-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }}-mix-build-${{ github.event.pull_request.base.sha }}- + ${{ runner.os }}-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }}-mix-build- + + - run: mix local.rebar --force --if-missing + shell: bash + + - run: mix local.hex --force --if-missing + shell: bash + + - run: mix deps.get + shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..de16cf6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,13 @@ +name: Continuous Integration +on: [pull_request, workflow_dispatch] + +permissions: read-all + +concurrency: + group: ${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + test-and-lint-station-ui: + uses: ./.github/workflows/test-station-ui.yml + secrets: inherit diff --git a/.github/workflows/test-station-ui.yml b/.github/workflows/test-station-ui.yml new file mode 100644 index 0000000..608d849 --- /dev/null +++ b/.github/workflows/test-station-ui.yml @@ -0,0 +1,48 @@ +name: Test Station UI + +on: [workflow_call, workflow_dispatch] + +permissions: + contents: read + +env: + MIX_ENV: test + ELIXIR_VERSION: 1.15.7 + OTP_VERSION: 26.1.2 + +jobs: + compile: + name: Compile + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Elixir app for CI + uses: ./.github/actions/setup-sui-for-ci + - run: mix compile --force --warnings-as-errors + check_formatted: + name: Check Formatted + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Elixir app for CI + uses: ./.github/actions/setup-sui-for-ci + - run: mix deps.compile + - run: mix format --check-formatted + + credo: + name: Credo + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Elixir app for CI + uses: ./.github/actions/setup-sui-for-ci + - run: mix credo + + run_tests: + name: Run Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Elixir app for CI + uses: ./.github/actions/setup-sui-for-ci + - run: mix test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2a5391 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +station_ui-*.tar + +# Temporary files, for example, from tests. +/tmp/ + +node_modules/ +priv/static/assets/ +.vscode/ + +.elixir_ls/ \ No newline at end of file diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..a30d2ad --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +erlang 26.2.5.4 +elixir 1.17.3-otp-26 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..51144a7 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,7 @@ +Copyright 2024 DockYard, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f615b0 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# StationUI + +Station UI is a [design system](https://www.figma.com/community/file/1338983767724300048) and component library built by DockYard to accelerate product development. Built on Elixir/Phoenix and TailwindCSS, the components were designed with extensibility in mind. You can use them as is in order to ship features quickly, or customize their functionality and styling without ever feeling locked in. + +In addition to these benefits, Station UI also replaces Phoenix Core Components. Station UI makes this seamless by including a compatibility layer with the same API as Phoenix Core Components, allowing the generators in the phoenix ecosystem (live, auth, etc.) to render with Station UI components without any changes to the generated code. + +## Installing Station UI + +### From Repo + +1. Add `{:station_ui, github: "dockyard/station-ui"}` to your deps in `mix.exs` +1. Inside your project root, run `mix station_ui.install`. Note the project must be running the same version of Elixir as the one you ran `mix archive.install` with + +## Local Development + +For detailed instructions on setting up and making changes in your development environment, see: [Demo App](demo_app/README.md). + +To run the application locally, navigate to the `/demo_app` folder: + +``` +cd demo_app/ +iex -S mix phx.server +``` + +Once running, open [http://localhost:4000/](http://localhost:4000/) in a browser to view. diff --git a/lib/mix/tasks/station_ui.install.ex b/lib/mix/tasks/station_ui.install.ex new file mode 100644 index 0000000..7673a7f --- /dev/null +++ b/lib/mix/tasks/station_ui.install.ex @@ -0,0 +1,231 @@ +defmodule Mix.Tasks.StationUi.Install do + use Mix.Task + + defmodule PathContext do + defstruct [:app_root_path, :web_path, :web_app_name, :web_assets_path, :umbrella] + + @assets_subpath "assets" + + @station_ui_subpath "station_ui" + + @sources_root_path __DIR__ |> Path.join("../../../sources") |> Path.expand() + + def create([]) do + create([File.cwd!()]) + end + + def create([app_root_path | _]) do + app_root_path = Path.expand(app_root_path) + + if File.exists?(app_root_path) do + %__MODULE__{ + app_root_path: app_root_path, + umbrella: umbrella?(app_root_path) + } + |> find_web_path() + |> then(&{:ok, &1}) + else + {:error, "Folder `#{app_root_path}` does not exist"} + end + end + + defp umbrella?(app_root_path) do + case File.ls(Path.join(app_root_path, "apps")) do + {:ok, _apps} -> true + _ -> false + end + end + + def web_app_ex_path(%PathContext{} = context) do + snake_web_app = Macro.underscore(context.web_app_name) + + Path.join(context.web_path, "../#{snake_web_app}.ex") + end + + defp find_web_path(%__MODULE__{web_path: nil} = context) do + web_path = web_path(context.app_root_path) + + web_app_name = Macro.camelize(Path.basename(web_path)) + + maybe_web_assets_path = + web_path + |> Path.join("../..") + |> Path.join(@assets_subpath) + |> Path.expand() + + web_assets_path = + if File.exists?(maybe_web_assets_path) do + maybe_web_assets_path + end + + %{ + context + | web_path: web_path, + web_app_name: web_app_name, + web_assets_path: web_assets_path + } + end + + defp web_path(app_root_path) do + apps_path = Path.join(app_root_path, "apps") + lib_path = Path.join(app_root_path, "lib") + apps_ls = File.ls(apps_path) + lib_ls = File.ls(lib_path) + + case {apps_ls, lib_ls} do + {{:ok, apps}, _} -> + find_web_path_child(apps, apps_path) |> add_lib() + + {_, {:ok, apps}} -> + find_web_path_child(apps, lib_path) + + _ -> + raise "No apps or lib folder found. Please run this from the root of your project or specify the path" + end + end + + defp find_web_path_child([], path), do: raise("_web file not found in #{path}") + + defp find_web_path_child([hd | tl], path) do + if String.ends_with?(hd, "_web") do + Path.join(path, hd) + else + find_web_path_child(tl, path) + end + end + + defp add_lib(path) do + Path.join([path, "lib", Path.basename(path)]) + end + + def sources_to_copy(%__MODULE__{} = context) do + template_sources = + @sources_root_path + |> Path.join("lib") + |> Path.join("**/*.*") + |> Path.wildcard() + |> Enum.map(&{&1, template_source_destination_file_path(context, &1), true}) + + js_sources = + @sources_root_path + |> Path.join("js") + |> Path.join("**/*.*") + |> Path.wildcard() + |> Enum.map(&{&1, asset_source_destination_file_path(context, &1), false}) + + css_sources = + @sources_root_path + |> Path.join("css") + |> Path.join("**/*.*") + |> Path.wildcard() + |> Enum.map(&{&1, asset_source_destination_file_path(context, &1), false}) + + font_sources = + @sources_root_path + |> Path.join("static/fonts") + |> Path.join("**/*.*") + |> Path.wildcard() + |> Enum.map(&{&1, font_source_destination_file_path(context, &1), false}) + + template_sources ++ js_sources ++ css_sources ++ font_sources + end + + defp template_source_destination_file_path(context, source_file_path) do + installer_lib = Path.join(@sources_root_path, "lib/station_ui") + relative_source_path = Path.relative_to(source_file_path, installer_lib) + + Path.join([context.web_path, @station_ui_subpath, relative_source_path]) + end + + defp asset_source_destination_file_path(context, source_file_path) do + Path.join( + context.web_assets_path, + Path.relative_to(source_file_path, @sources_root_path) + ) + end + + defp font_source_destination_file_path(context, source_file_path) do + Path.join([ + context.web_assets_path, + "../priv", + Path.relative_to(source_file_path, @sources_root_path) + ]) + end + end + + def run(args) do + dry_run = Enum.any?(args, &(&1 == "--dry-run")) + + Mix.shell().info("Running StationUI Installer") + + {:ok, %PathContext{} = context} = PathContext.create(args) + + Mix.shell().info("Running for #{context.web_app_name} found at path: #{context.web_path}") + + for {source_path, dest_path, template} <- PathContext.sources_to_copy(context) do + binary = File.read!(source_path) + + binary = + if template do + binary + |> String.replace("StationUI.HTML", "#{context.web_app_name}.StationUI.HTML") + |> String.replace("StationUI.Gettext", "#{context.web_app_name}.Gettext") + else + binary + end + + Mix.shell().info("Writing #{dest_path} #{if dry_run, do: "(dry run)"}") + + if not dry_run do + File.mkdir_p!(Path.dirname(dest_path)) + File.write!(dest_path, binary) + end + end + + tailwind_config_path = Path.join(context.web_assets_path, "tailwind.config.js") + tailwind_content = File.read!(tailwind_config_path) + module_exports_line = ~s|module.exports = {\n| + sui_preset = " presets: [\n require('./js/station-ui.js'),\n ],\n" + + tailwind_content = + String.replace(tailwind_content, module_exports_line, module_exports_line <> sui_preset) + + File.write!(tailwind_config_path, tailwind_content) + IO.puts("Updated #{tailwind_config_path}") + + app_css_path = Path.join(context.web_assets_path, "css/app.css") + app_css_content = File.read!(app_css_path) + + File.write!( + app_css_path, + ~s|@import "./station-ui.css";\n@import "./station-ui-fonts.css";\n| <> app_css_content + ) + + IO.puts("Updated #{app_css_path}") + + web_app_ex_path = PathContext.web_app_ex_path(context) + web_app_ex_content = File.read!(web_app_ex_path) + + web_app_ex_content = + String.replace( + web_app_ex_content, + "import #{context.web_app_name}.CoreComponents", + "use #{context.web_app_name}.StationUI.HTML" + ) + + File.write!(web_app_ex_path, web_app_ex_content) + IO.puts("Updated #{web_app_ex_path}") + + core_components_path = Path.join(context.web_path, "components/core_components.ex") + [first | rest] = File.read!(core_components_path) |> String.trim() |> String.split("\n") + last = List.last(rest) + File.rm!(core_components_path) + + File.write( + core_components_path, + Enum.join([first, " # Replaced by StationUI components", last], "\n") + ) + + IO.puts("Replaced #{core_components_path}") + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..c4e65d7 --- /dev/null +++ b/mix.exs @@ -0,0 +1,57 @@ +defmodule StationUI.MixProject do + use Mix.Project + + def project do + [ + app: :station_ui, + version: "0.1.0", + elixir: "~> 1.14", + elixirc_paths: elixirc_paths(Mix.env(), Application.get_env(:station_ui, :use_source_components, false)), + start_permanent: Mix.env() == :prod, + deps: deps(), + aliases: aliases(), + description: "StationUI", + package: [ + organization: "dockyard", + maintainers: [ + "DockYard" + ], + licenses: ["MIT"], + links: %{"GitHub" => "https://github.com/DockYard/station-ui"}, + files: ~w(lib templates css fonts js mix.exs README.md) + ] + ] + end + + def application do + [ + extra_applications: [:logger] + ] + end + + defp elixirc_paths(:test, true), do: ["lib", "sources/lib", "test/support"] + defp elixirc_paths(:test, false), do: ["lib", "test/support"] + defp elixirc_paths(_, true), do: ["lib", "sources/lib"] + defp elixirc_paths(_, false), do: ["lib"] + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:gettext, "~> 0.20"}, + {:phoenix, "~> 1.7.11"}, + {:phoenix_live_view, "~> 0.20.7"}, + {:credo, "~> 1.7.0", only: [:dev, :test], runtime: false}, + {:tailwind_formatter, "~> 0.3.5", only: [:dev, :test], runtime: false} + ] + end + + defp aliases do + [ + "phx.server": &phx_server/1 + ] + end + + defp phx_server(_) do + IO.puts("Running in wrong directory. cd into demo_web") + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..3f0f6e2 --- /dev/null +++ b/mix.lock @@ -0,0 +1,21 @@ +%{ + "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, + "castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"}, + "credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"}, + "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, + "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "gettext": {:hex, :gettext, "0.23.1", "821e619a240e6000db2fc16a574ef68b3bd7fe0167ccc264a81563cc93e67a31", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "19d744a36b809d810d610b57c27b934425859d158ebd56561bc41f7eeb8795db"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, + "phoenix": {:hex, :phoenix, "1.7.11", "1d88fc6b05ab0c735b250932c4e6e33bfa1c186f76dcf623d8dd52f07d6379c7", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "b1ec57f2e40316b306708fe59b92a16b9f6f4bf50ccfa41aa8c7feb79e0ec02a"}, + "phoenix_html": {:hex, :phoenix_html, "4.0.0", "4857ec2edaccd0934a923c2b0ba526c44a173c86b847e8db725172e9e51d11d6", [:mix], [], "hexpm", "cee794a052f243291d92fa3ccabcb4c29bb8d236f655fb03bcbdc3a8214b8d13"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.7", "278804219cc85e00f59a02a07b8ea591d99b219877a3b984fb77ac3fdebfb696", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4d533f5d6b09c6ff4fb1f41d61dcd90c7f076f25909d4a5481d71bd442b83dc9"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"}, + "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, + "tailwind_formatter": {:hex, :tailwind_formatter, "0.3.7", "2728d031e6803dfddf63f1dd7c64b5b9fd70ffdf635709c50f47589f4fb48861", [:mix], [{:phoenix_live_view, ">= 0.17.6", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "3d91ac4d4622505b09c0f4678512281515b4fbe7644f012da1bd2722f5880185"}, + "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.5", "9dfeee8269b27e958a65b3e235b7e447769f66b5b5925385f5a569269164a210", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "4b977ba4a01918acbf77045ff88de7f6972c2a009213c515a445c48f224ffce9"}, +} diff --git a/sources/css/station-ui-fonts.css b/sources/css/station-ui-fonts.css new file mode 100644 index 0000000..9c99519 --- /dev/null +++ b/sources/css/station-ui-fonts.css @@ -0,0 +1,153 @@ +/* Inter-100 */ +@font-face { + font-family: "Inter"; + font-weight: 100; + font-style: normal; + font-display: swap; + src: url("/fonts/inter/Inter-Thin.woff2"), + /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url("/fonts/inter/Inter-Thin.woff"), + /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+, iOS 5+ */ + url("/fonts/inter/Inter-Thin.ttf"); + /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} + +/* Inter-200 */ +@font-face { + font-family: "Inter"; + font-weight: 200; + font-style: normal; + font-display: swap; + src: url("/fonts/inter/Inter-ExtraLight.woff2"), + /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url("/fonts/inter/Inter-ExtraLight.woff"), + /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+, iOS 5+ */ + url("/fonts/inter/Inter-ExtraLight.ttf"); + /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} + +/* Inter-300 */ +@font-face { + font-family: "Inter"; + font-weight: 300; + font-style: normal; + font-display: swap; + src: url("/fonts/inter/Inter-Light.woff2"), + /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url("/fonts/inter/Inter-Light.woff"), + /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+, iOS 5+ */ + url("/fonts/inter/Inter-Light.ttf"); + /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} + +/* Inter-400 */ +@font-face { + font-family: "Inter"; + font-weight: 400; + font-style: normal; + font-display: swap; + src: url("/fonts/inter/Inter-Regular.woff2"), + /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url("/fonts/inter/Inter-Regular.woff"), + /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+, iOS 5+ */ + url("/fonts/inter/Inter-Regular.ttf"); + /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} + +/* Inter-500 */ +@font-face { + font-family: "Inter"; + font-weight: 500; + font-style: normal; + font-display: swap; + src: url("/fonts/inter/Inter-Medium.woff2"), + /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url("/fonts/inter/Inter-Medium.woff"), + /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+, iOS 5+ */ + url("/fonts/inter/Inter-Medium.ttf"); + /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} + +/* Inter-600 */ +@font-face { + font-family: "Inter"; + font-weight: 600; + font-style: normal; + font-display: swap; + src: url("/fonts/inter/Inter-SemiBold.woff2"), + /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url("/fonts/inter/Inter-SemiBold.woff"), + /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+, iOS 5+ */ + url("/fonts/inter/Inter-SemiBold.ttf"); + /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} + +/* Inter-700 */ +@font-face { + font-family: "Inter"; + font-weight: 700; + font-style: normal; + font-display: swap; + src: url("/fonts/inter/Inter-Bold.woff2"), + /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url("/fonts/inter/Inter-Bold.woff"), + /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+, iOS 5+ */ + url("/fonts/inter/Inter-Bold.ttf"); + /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} + +/* Inter-800 */ +@font-face { + font-family: "Inter"; + font-weight: 800; + font-style: normal; + font-display: swap; + src: url("/fonts/inter/Inter-ExtraBold.woff2"), + /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url("/fonts/inter/Inter-ExtraBold.woff"), + /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+, iOS 5+ */ + url("/fonts/inter/Inter-ExtraBold.ttf"); + /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} + +/* Inter-900 */ +@font-face { + font-family: "Inter"; + font-weight: 900; + font-style: normal; + font-display: swap; + src: url("/fonts/inter/Inter-Black.woff2"), + /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url("/fonts/inter/Inter-Black.woff"), + /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+, iOS 5+ */ + url("/fonts/inter/Inter-Black.ttf"); + /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} + +/* roboto-mono-300 - latin_latin-ext */ +@font-face { + font-family: "Roboto Mono"; + font-weight: 300; + font-style: normal; + font-display: swap; + src: url("/fonts/roboto-mono/roboto-mono-v23-latin_latin-ext-300.woff2"), + /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url("/fonts/roboto-mono/roboto-mono-v23-latin_latin-ext-300.woff"), + /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+, iOS 5+ */ + url("/fonts/roboto-mono/roboto-mono-v23-latin_latin-ext-300.ttf"); + /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} + +/* roboto-mono-500 - latin_latin-ext */ +@font-face { + font-family: "Roboto Mono"; + font-weight: 500; + font-style: normal; + font-display: swap; + src: url("/fonts/roboto-mono/roboto-mono-v23-latin_latin-ext-500.woff2"), + /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ + url("/fonts/roboto-mono/roboto-mono-v23-latin_latin-ext-500.woff"), + /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+, iOS 5+ */ + url("/fonts/roboto-mono/roboto-mono-v23-latin_latin-ext-500.ttf"); + /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */ +} diff --git a/sources/css/station-ui.css b/sources/css/station-ui.css new file mode 100644 index 0000000..5182614 --- /dev/null +++ b/sources/css/station-ui.css @@ -0,0 +1,111 @@ +@layer base { + :root { + /* primary */ + --sui-brand-primary: theme("colors.indigo.700"); + --sui-brand-primary-bg: theme("colors.indigo.600"); + --sui-brand-primary-bg-disabled: theme("colors.slate.50"); + --sui-brand-primary-muted: theme("colors.indigo.500"); + --sui-brand-primary-shadow: theme("colors.slate.100"); + --sui-brand-primary-text: theme("colors.slate.800"); + --sui-brand-primary-text-inverted: theme("colors.white"); + --sui-brand-primary-text-disabled: theme("colors.slate.300"); + --sui-brand-primary-border: theme("colors.slate.300"); + --sui-brand-primary-border-inverted: theme("colors.slate.600"); + --sui-brand-primary-focus: theme("colors.purple.500"); + --sui-brand-primary-error: theme("colors.rose.500"); + --sui-brand-primary-success: theme("colors.emerald.500"); + --sui-brand-primary-icon: theme("colors.slate.500"); + --sui-brand-primary-icon-inverted: theme("colors.slate.400"); + + /* secondary */ + --sui-brand-secondary-bg: theme("colors.white"); + --sui-brand-secondary-bg-inverted: theme("colors.black"); + --sui-brand-secondary-text: theme("colors.slate.600"); + --sui-brand-secondary-text-muted: theme("colors.gray.500"); + --sui-brand-secondary-text-inverted: theme("colors.slate.400"); + + /* buttons */ + --sui-text-btn-disabled: theme("colors.slate.300"); + --sui-bg-btn-disabled: theme("colors.slate.50"); + --sui-border-btn-disabled: theme("colors.slate.50"); + + /* forms */ + --sui-form-bg-slider-progress: theme("colors.indigo.600"); + --sui-form-bg-slider-progress-disabled: theme("colors.zinc.300"); + --sui-form-bg-slider-thumb: theme("colors.indigo.600"); + --sui-form-bg-slider-thumb-active: theme("colors.indigo.800"); + --sui-form-bg-slider-thumb-disabled: theme("colors.slate.100"); + --sui-form-bg-slider-thumb-hover: theme("colors.indigo.500"); + --sui-form-bg-slider-track: theme("colors.white"); + --sui-form-bg-slider-track-disabled: theme("colors.slate.50"); + --sui-form-border-slider-thumb: theme("colors.indigo.500"); + --sui-form-border-slider-thumb-active: theme("colors.indigo.700"); + --sui-form-border-slider-thumb-disabled: theme("colors.zinc.300"); + --sui-form-border-slider-thumb-hover: theme("colors.indigo.400"); + --sui-form-border-slider-track: theme("colors.gray.400"); + --sui-form-border-slider-track-disabled: theme("colors.zinc.300"); + --sui-form-text: var(--sui-brand-primary-text); + --sui-form-text-disabled: theme("colors.gray.500"); + --sui-form-text-error: theme("colors.rose.700"); + } + + .sui-primary { + --sui-text-btn: theme("colors.white"); + --sui-text-btn-hover: theme("colors.white"); + --sui-text-btn-active: theme("colors.white"); + --sui-bg-btn: theme("colors.indigo.700"); + --sui-bg-btn-hover: theme("colors.indigo.600"); + --sui-bg-btn-active: theme("colors.indigo.800"); + --sui-border-btn: theme("colors.indigo.700"); + --sui-border-btn-hover: theme("colors.indigo.600"); + --sui-border-btn-active: theme("colors.indigo.800"); + } + + .sui-secondary { + --sui-text-btn: theme("colors.slate.800"); + --sui-text-btn-hover: theme("colors.slate.800"); + --sui-text-btn-active: theme("colors.slate.800"); + --sui-bg-btn: theme("colors.white"); + --sui-bg-btn-hover: theme("colors.slate.50"); + --sui-bg-btn-active: theme("colors.slate.200"); + --sui-border-btn: theme("colors.slate.800"); + --sui-border-btn-hover: theme("colors.slate.800"); + --sui-border-btn-active: theme("colors.slate.800"); + } + + .sui-tertiary { + --sui-text-btn: theme("colors.white"); + --sui-text-btn-hover: theme("colors.white"); + --sui-text-btn-active: theme("colors.white"); + --sui-bg-btn: theme("colors.slate.800"); + --sui-bg-btn-hover: theme("colors.slate.700"); + --sui-bg-btn-active: theme("colors.slate.900"); + --sui-border-btn: theme("colors.slate.800"); + --sui-border-btn-hover: theme("colors.slate.700"); + --sui-border-btn-active: theme("colors.slate.900"); + } + + .sui-primary-destructive { + --sui-text-btn: theme("colors.white"); + --sui-text-btn-hover: theme("colors.white"); + --sui-text-btn-active: theme("colors.white"); + --sui-bg-btn: theme("colors.rose.700"); + --sui-bg-btn-hover: theme("colors.rose.600"); + --sui-bg-btn-active: theme("colors.rose.800"); + --sui-border-btn: theme("colors.rose.700"); + --sui-border-btn-hover: theme("colors.rose.600"); + --sui-border-btn-active: theme("colors.rose.800"); + } + + .sui-secondary-destructive { + --sui-text-btn: theme("colors.rose.700"); + --sui-text-btn-hover: theme("colors.rose.600"); + --sui-text-btn-active: theme("colors.rose.800"); + --sui-bg-btn: theme("colors.white"); + --sui-bg-btn-hover: theme("colors.white"); + --sui-bg-btn-active: theme("colors.white"); + --sui-border-btn: theme("colors.rose.700"); + --sui-border-btn-hover: theme("colors.rose.600"); + --sui-border-btn-active: theme("colors.rose.800"); + } +} diff --git a/sources/js/station-ui.js b/sources/js/station-ui.js new file mode 100644 index 0000000..1b6a795 --- /dev/null +++ b/sources/js/station-ui.js @@ -0,0 +1,45 @@ +// Tailwind CSS presets for Station UI +const defaultTheme = require("tailwindcss/defaultTheme"); + +module.exports = { + theme: { + container: { + center: true, + }, + extend: { + animation: { + "spin-reverse": "spin-reverse 1s linear infinite", + }, + fontFamily: { + sans: ["Inter", ...defaultTheme.fontFamily.sans], + mono: ["Roboto Mono", ...defaultTheme.fontFamily.mono], + }, + boxShadow: { + "inner-sm": "inset 0 1px 2px 0 rgb(0 0 0 / 0.05)", // shadow-sm + "inner-md": + "inset 0 4px 6px -1px rgb(0 0 0 / 0.1), inset 0 2px 4px -2px rgb(0 0 0 / 0.1)", // shadow-md + "inner-lg": + "inset 0 10px 15px -3px rgb(0 0 0 / 0.1), inset 0 4px 6px -4px rgb(0 0 0 / 0.1)", // shadow-lg + "inner-xl": + "inset 0 20px 25px -5px rgb(0 0 0 / 0.1), inset 0 8px 10px -6px rgb(0 0 0 / 0.1)", // shadow-xl + "inner-2xl": "inset 0 25px 50px -12px rgb(0 0 0 / 0.25)", // shadow-2xl + }, + keyframes: { + "spin-reverse": { + from: { + transform: "rotate(360deg)", + }, + }, + }, + spacing: { + 4.5: "1.125rem", //18px + }, + transitionProperty: { + "grid-rows": "grid-template-rows", + }, + } + }, + plugins: [ + require("@tailwindcss/container-queries"), + ] +} diff --git a/sources/lib/station_ui/HTML.ex b/sources/lib/station_ui/HTML.ex new file mode 100644 index 0000000..8bbfc9d --- /dev/null +++ b/sources/lib/station_ui/HTML.ex @@ -0,0 +1,31 @@ +defmodule StationUI.HTML do + defmacro __using__(_) do + quote do + import StationUI.HTML.{ + Avatars, + Banners, + Buttons, + Accordion, + Cards, + Footer, + Forms, + Icons, + LegacyCoreComponents, + NotificationBadges, + Inputs, + Modals, + Navbar, + Pagination, + Spinners, + StatusBadges, + TabGroup, + Tags, + Toast, + Toolbars, + Tooltips, + TableHeader, + TableCell + } + end + end +end diff --git a/sources/lib/station_ui/HTML/accordion.ex b/sources/lib/station_ui/HTML/accordion.ex new file mode 100644 index 0000000..8a2f888 --- /dev/null +++ b/sources/lib/station_ui/HTML/accordion.ex @@ -0,0 +1,139 @@ +defmodule StationUI.HTML.Accordion do + use Phoenix.Component + + import StationUI.HTML.Icons, only: [icon: 1] + alias Phoenix.LiveView.JS + + @moduledoc """ + The accordion component renders a list of items with child content that can be expanded or collapsed. + + ## Example + + <.accordion_set> + <:header> + Title something 1 + + <:content> + Content something 1 + + + + Suggested size classes + + The Default size for accordions is "md" but the size can be change by passing in these additional classes + using `header_size_class="..."` and `content_size_class="..."` as follows + + header_size_class: + + sm: "p-1 text-base sm:text-lg gap-x-0.5" + md: "p-1 text-base sm:text-lg md:text-xl md:py-1 md:pr-1 md:pl-1.5 md:gap-x-1" + lg: "p-1 text-base sm:text-lg md:text-xl lg:text-2xl md:py-1 md:pr-1 md:pl-1.5 lg:pl-2 md:gap-x-1 lg:gap-x-1.5" + xl: "p-1 text-base sm:text-lg md:text-xl lg:text-2xl xl:text-3xl md:pt-1 md:pb-0 md:pr-1 md:pl-1.5 lg:pl-4 sm:gap-x-3 md:gap-x-4 lg:gap-x-5" + + content_size_class: + + sm: "text-base" + md: "grid transition-grid-rows text-base md:text-lg" + lg: "md:text-lg lg:text-xl" + xl: "md:text-lg lg:text-xl xl:text-2xl" + """ + + def accordion(assigns) do + ~H""" + <.accordion_set> + <:header> + Title something 1 + + <:content> + Content something 1 + + + """ + end + + slot :header, required: true do + attr :button_id, :string + end + + slot :content, required: true + attr :header_size_class, :string, default: "text-base sm:text-lg md:text-xl" + attr :content_size_class, :string, default: "text-base md:text-lg" + attr :rest, :global + + def accordion_set(assigns) do + assigns = + assigns + |> assign(:header, List.wrap(assigns.header)) + |> assign(:content, List.wrap(assigns.content)) + |> assign(:random_id, :rand.uniform(9999)) + |> assign(:items, Enum.with_index(Enum.zip(List.wrap(assigns.header), List.wrap(assigns.content)))) + + ~H""" +
+
+ <% # Accordion Trigger %> + + + <% # Accordion Content %> + +
+
+ """ + end +end diff --git a/sources/lib/station_ui/HTML/avatars.ex b/sources/lib/station_ui/HTML/avatars.ex new file mode 100644 index 0000000..e2e366f --- /dev/null +++ b/sources/lib/station_ui/HTML/avatars.ex @@ -0,0 +1,239 @@ +defmodule StationUI.HTML.Avatars do + use Phoenix.Component + + import StationUI.HTML.StatusBadges, only: [status_badge: 1] + + @moduledoc """ + The avatar component renders initials, an SVG, or an image thumbnail to represent a user. + Avatars can be displayed as single items or combined into a horizontal stack. + + Sets up an avatar stack. + + ## Stack Example + + <.avatar_stack overflow_link={~p"/avatars/link"} display_max={2}> + <:avatar> + <.avatar .../> + + <:avatar> + <.avatar .../> + + <:avatar> + <.avatar .../> + + <:avatar> + <.avatar .../> + + <.avatar_stack> + + """ + @stack_base_classes [ + "flex items-start [&_div]:flex [&_div]:flex-row-reverse", + "[&>a]:z-20 [&>a]:hover:z-40 [&_div_a_figure]:z-10", + "[&_div_a]:hover:z-30 [&_a:focus-visible]:z-50 [&_a]:active:z-50 [&_a:hover_figure]:ml-0 [&_a:focus-visible_figure]:ml-0" + ] + + def stack_base_classes, do: @stack_base_classes + + attr(:class, :any, default: "[&_div]:ml-1.5 [&_div_figure]:-ml-3.5") + attr(:display_max, :integer, default: 3) + attr(:total_count, :integer, default: nil) + attr(:overflow_link, :string, required: true) + + slot(:avatar) + + def avatar_stack(assigns) do + assigns = + assigns + |> assign(:total_count, assigns.total_count || length(assigns.avatar)) + + ~H""" +
+ <.avatar_link :if={@total_count > @display_max} to={@overflow_link} variant="initials" class="h-[42px] w-[42px] border-[--sui-brand-primary-border]"> + <:initials count={true}>+<%= @total_count - @display_max %> + + +
+ <%= for {avatar, i} <- Enum.with_index(@avatar), i < @display_max do %> + <%= render_slot(avatar) %> + <% end %> +
+
+ """ + end + + @doc """ + An avatar that links somewhere. + + ## Example + + <.avatar_link to={~p"/some/link"} variant="placeholder" /> + + """ + @link_base_classes "rounded-full outline-none transition hover:ring-2 hover:ring-[--sui-brand-primary-muted] focus-visible:ring-[--sui-brand-primary-focus] focus-visible:ring-offset-4 active:ring-[--sui-brand-primary]" + + def link_base_classes, do: @link_base_classes + + attr(:status, :string, values: ~w[active inactive deactivated pending]) + attr(:variant, :string, values: ~w[image initials placeholder]) + attr(:index, :integer) + attr(:name, :string, default: nil) + attr(:image_src, :string, default: nil) + attr(:to, :string, required: true) + attr(:link_class, :any, default: "focus-visible:ring-2 active:ring-1") + attr(:class, :any, default: nil) + + # These are all passed through. + slot :initials do + attr(:count, :boolean) + end + + slot(:placeholder) + + def avatar_link(assigns) do + assigns = + case assigns do + %{class: nil} = assigns -> Map.drop(assigns, [:class]) + assigns -> assigns + end + + ~H""" + + <.avatar {Map.drop(assigns, [:link_class])} /> + + """ + end + + @doc """ + A single avatar + + ## Examples + + Avatar with initials, a border, and an active status icon: + + <.avatar variant="initials" status="active" class="h-[42px] w-[42px] border-[--sui-brand-primary]" /> + + Avatar with placeholder image with a pending status icon: + + <.avatar variant="placeholder" status="pending" /> + + Suggested classes for various sizes: + - xs -> "h-6 w-6 [&_svg]:w-3 text-xs" + - sm -> "h-8 w-8 [&_svg]:w-4 text-sm" + - md -> "h-[42px] w-[42px] [&_svg]:w-[21px]" (default) + - lg -> "h-[52px] w-[52px] [&_svg]:w-[26px] text-lg" + - xl -> "h-16 w-16 [&_svg]:w-8 text-lg" + + """ + @figure_base_classes "relative flex items-center justify-center border rounded-full bg-slate-50 transition-all duration-200 font-sans font-medium uppercase text-[--sui-brand-primary]" + + def figure_base_classes, do: @figure_base_classes + + attr(:status, :string, values: ~w[active inactive deactivated pending]) + attr(:variant, :string, values: ~w[image initials placeholder]) + attr(:index, :integer) + attr(:name, :string, default: nil) + attr(:image_src, :string, default: "") + attr(:class, :any, default: "h-[42px] w-[42px] [&_svg]:w-[21px] border-transparent") + + slot :initials do + attr(:count, :boolean) + end + + # We may have to deal with applying styles to placeholders? + slot(:placeholder) + + def avatar(%{variant: "image"} = assigns) do + ~H""" +
+ {@name + <.avatar_status_badge :if={assigns[:status]} status={@status} /> +
+ """ + end + + def avatar(%{variant: "initials"} = assigns) do + ~H""" +
+
+ + + <%= render_slot(@initials) %> + + <%= @name %> +
+ <.avatar_status_badge :if={assigns[:status]} status={@status} /> +
+ """ + end + + def avatar(%{variant: "placeholder"} = assigns) do + ~H""" +
+ <%= render_slot(@placeholder) || default_avatar_placeholder_icon(assigns) %> + <.avatar_status_badge :if={assigns[:status]} status={@status} /> +
+ """ + end + + defp initials_from_name(name) do + String.split(name) |> Enum.map_join(&String.first/1) + end + + @doc """ + The default placeholder icon for a placeholder variant of an avatar. + """ + attr(:name, :string, default: nil) + + def default_avatar_placeholder_icon(assigns) do + ~H""" + + <%= @name %> + + + + + + """ + end + + @doc """ + An avatar-specific status icon. + """ + attr(:status, :string, required: true, values: ~w[active inactive deactivated pending]) + attr(:class, :any, default: nil, doc: "additional or overriding classes") + + def avatar_status_badge(assigns) do + ~H""" + <.status_badge + :if={@status} + status={@status} + class={[ + "absolute -right-px -bottom-px z-10 transition-opacity duration-200", + "after:absolute after:inset-0", + "after:h-full after:w-full after:rounded-full", + "w-3 [&>span]:w-0.5" + ]} + /> + """ + end +end diff --git a/sources/lib/station_ui/HTML/banners.ex b/sources/lib/station_ui/HTML/banners.ex new file mode 100644 index 0000000..4192d63 --- /dev/null +++ b/sources/lib/station_ui/HTML/banners.ex @@ -0,0 +1,72 @@ +defmodule StationUI.HTML.Banners do + use Phoenix.Component + + import StationUI.HTML.Icons, only: [icon: 1] + import StationUI.HTML.Buttons + + alias Phoenix.LiveView.JS + + @base_classes "max-w-[800px] text-[--sui-brand-primary-text] w-full rounded-lg border py-2.5 pl-3" + defp base_classes, do: @base_classes + + @doc """ + The banner component renders an enclosed title, description, and close button. + The title content goes into the main inner_block slot. + The optional secondary (lower) content goes into the secondary slot. + + ## Examples + + Default banner with left icon, title, and secondary text: + + <.banner id="icon-title-and-secondary"> + <.icon name="hero-information-circle-solid" class="text-[--sui-brand-primary] shrink-0" /> +

Default Banner with Icon and Secondary

+ <:secondary> + Secondary text. + + + + Banner of default size but without border: + + <.banner id="no-border" class="border-transparent [&_span]:h-6 [&_span]:w-6 text-base"> + ... + + + Suggested classes for various text sizes and the default border styling: + + - xs -> "border-[--sui-brand-primary-border] [&_span]:h-3.5 [&_span]:w-3.5 text-xs" + - sm -> "border-[--sui-brand-primary-border] [&_span]:h-4.5 [&_span]:w-4.5 text-sm" + - md -> "border-[--sui-brand-primary-border] [&_span]:h-6 [&_span]:w-6 text-base" (the default) + - lg -> "border-[--sui-brand-primary-border] [&_span]:h-9 [&_span]:w-9 text-xl" + - xl -> "border-[--sui-brand-primary-border] [&_span]:h-12 [&_span]:w-12 text-3xl" + """ + + slot :inner_block, required: true + slot :secondary + + attr :id, :string, required: true + attr :class, :any, default: "border-[--sui-brand-primary-border] [&_span]:h-6 [&_span]:w-6 text-base" + attr :on_cancel, JS, default: %JS{} + + def banner(assigns) do + ~H""" +
+
+
+ <%= render_slot(@inner_block) %> +
+ <.button class="sui-secondary min-h-11 border-0 bg-white" aria-label="Dismiss" phx-click={hide_banner(@on_cancel, @id)}> + <.icon name="hero-x-mark" /> + +
+

<%= render_slot(@secondary) %>

+
+ """ + end + + defp hide_banner(js, id) do + js + |> JS.hide(to: "##{id}") + |> JS.pop_focus() + end +end diff --git a/sources/lib/station_ui/HTML/buttons.ex b/sources/lib/station_ui/HTML/buttons.ex new file mode 100644 index 0000000..44bd6f5 --- /dev/null +++ b/sources/lib/station_ui/HTML/buttons.ex @@ -0,0 +1,84 @@ +defmodule StationUI.HTML.Buttons do + use Phoenix.Component + + @moduledoc """ + The button component renders a + """ + end + + defp base_classes do + ~w" + [:where(&)]:rounded-lg + [:where(&)]:text-base + + py-[7px] + bg-[--sui-bg-btn] + border-[--sui-border-btn] + text-[--sui-text-btn] + inline-flex + items-center + justify-center + gap-x-1.5 + whitespace-nowrap + border + px-4 + font-bold + + hover:bg-[--sui-bg-btn-hover] + hover:border-[--sui-border-btn-hover] + hover:text-[--sui-text-btn-hover] + + focus-visible:outline-none + focus-visible:ring-2 + focus-visible:ring-purple-500 + focus-visible:ring-offset-4 + + active:bg-[--sui-bg-btn-active] + active:border-[--sui-border-btn-active] + active:text-[--sui-text-btn-active] + + disabled:bg-[--sui-bg-btn-disabled] + disabled:border-[--sui-border-btn-disabled] + disabled:text-[--sui-text-btn-disabled] + + lg:gap-x-2 + " + end +end diff --git a/sources/lib/station_ui/HTML/cards.ex b/sources/lib/station_ui/HTML/cards.ex new file mode 100644 index 0000000..273b511 --- /dev/null +++ b/sources/lib/station_ui/HTML/cards.ex @@ -0,0 +1,180 @@ +defmodule StationUI.HTML.Cards do + use Phoenix.Component + + @moduledoc """ + The cards component renders a self-contained area of content which can contain: + - Title + - Image + - Description + - Date + - Read More link + + The card can utilize either a vertical or horizontal layout. + + ## Examples + + ### Vertical Card + + <.card> + <:header> + A whale leaps out of the water + + <:content> +
+ +

+ The Whales Are Here! +

+

Nov 12, 2022

+
+

+ Lorem ipsum, dolor sit amet consectetur adipisicing elit. Facilis fugiat, aliquam assumenda repellat rerum nostrum. +

+ + + Read More + + + + + ### Horizontal Card + + <.card_horizontal> + <:header> + A whale leaps out of the water + + <:content> +
+

+ Headline +

+

+ Lorem ipsum, dolor sit amet consectetur adipisicing elit. Facilis fugiat, aliquam assumenda repellat rerum nostrum. +

+
+ +
+ <.button class="w-10 h-10 rounded-full border-0 sui-secondary"> + <.icon name="hero-face-smile-solid" class="w-5 h-5 shrink-0" /> + + <.button class="w-10 h-10 rounded-full border-0 sui-secondary"> + <.icon name="hero-face-smile-solid" class="w-5 h-5 shrink-0" /> + + <.button class="w-10 h-10 rounded-full border-0 sui-secondary"> + <.icon name="hero-face-smile-solid" class="w-5 h-5 shrink-0" /> + +
+ + + """ + + @base_classes "@container min-w-[200px] w-full h-full" + defp base_classes, do: @base_classes + + @base_inner_classes "overflow-hidden drop-shadow-md @[425px]:drop-shadow-lg @[625px]:drop-shadow-xl @[850px]:drop-shadow-2xl rounded-xl w-auto h-full" + defp base_inner_classes, do: @base_inner_classes + + @base_content_classes "grid gap-0.5 @[350px]:gap-1 @[425px]:gap-2 p-2 @[425px]:px-4 @[625px]:px-6 @[625px]:py-3 @[850px]:px-8 @[850px]:py-4" + defp base_content_classes, do: @base_content_classes + + attr :class, :any, default: "" + + slot :header + + slot :content, required: true do + attr :class, :string + end + + def card(assigns) do + ~H""" +
+
+
+ <%= render_slot(header) %> +
+ <.content_card slot={@content} /> +
+
+ """ + end + + attr :slot, :any, required: true + + defp content_card(assigns) do + class = + case assigns.slot do + [%{class: class} | _] -> class + _ -> "bg-white" + end + + assigns = assign(assigns, :class, class) + + ~H""" +
+ <%= render_slot(@slot) %> +
+ """ + end + + @base_horizontal_classes "@container min-w-[200px] w-full h-full" + defp base_horizontal_classes, do: @base_horizontal_classes + + @base_horizontal_inner_classes "overflow-hidden drop-shadow-md @[425px]:drop-shadow-lg @[625px]:drop-shadow-xl @[850px]:drop-shadow-2xl rounded-xl w-full flex" + defp base_horizontal_inner_classes, do: @base_horizontal_inner_classes + + @base_horizontal_content_classes "flex w-full gap-1 py-2 pl-2 @[425px]:py-4 @[425px]:pl-4 @[625px]:py-6 @[625px]:pl-6 @[850px]:py-8 @[850px]:pl-8" + defp base_horizontal_content_classes, do: @base_horizontal_content_classes + + attr :class, :any, default: "" + slot :inner_block, required: true + slot :header + + slot :content do + attr :class, :string + end + + def card_horizontal(assigns) do + ~H""" +
+
+
+ <%= render_slot(header) %> +
+ <.content_card_horizontal slot={@content} /> +
+
+ """ + end + + attr :slot, :any, required: true + + defp content_card_horizontal(assigns) do + class = + case assigns.slot do + [%{class: class} | _] -> class + _ -> "bg-white" + end + + assigns = assign(assigns, :class, class) + + ~H""" +
+ <%= render_slot(@slot) %> +
+ """ + end +end diff --git a/sources/lib/station_ui/HTML/footer.ex b/sources/lib/station_ui/HTML/footer.ex new file mode 100644 index 0000000..5d2d9e1 --- /dev/null +++ b/sources/lib/station_ui/HTML/footer.ex @@ -0,0 +1,224 @@ +defmodule StationUI.HTML.Footer do + use Phoenix.Component + + @moduledoc """ + The Footer component includes "simple" (default), and "columns" variant. + The default variant will list any footer_link slotted in horizontally, while + the columns variant will loop over a grouped list of links under a heading. + + ## Default Footer example + <.footer logo_src={~p"/images/my_logo.png"} logo_alt_text="[Organization name] logo"> + <:footer_link> + <.link patch={~p"/"}> + Home + + + <:footer_link> + <.link href="https://www.foo.com/blog"> + Blog + + + + + ## columns variant + <.footer variant="columns" logo_src={~p"/images/my_logo.png"}> + <:column heading="One"> + <.column_items> + <:footer_link> + <.link patch={~p"/"}> + Home + + + <:footer_link > + <.link href="https://www.foo.com/blog"> + Blog + + + + + <:column heading="Two"> + <.column_items> + <:footer_link> + <.link patch={~p"/"}> + Home + + + <:footer_link> + <.link href="https://www.foo.com/blog"> + Blog + + + + + <./footer> + + ## Both variants accept social links and icons + <.footer> + <:social_icon url="https://www.instagram.com" title="Instagram"> + + ... + + + <:social_icon url="https://www.facebook.com" title="Facebook" class="text-[#1877F2]"> + + ... + + + + """ + + slot :inner_block + + slot :footer_link do + attr :class, :string + end + + slot :column do + attr :heading, :string, required: true + end + + slot :social_icon do + attr :url, :string, required: true + attr :title, :string, required: true + attr :class, :string + end + + attr :variant, :string, default: "simple", values: ~w[simple columns] + attr :logo_src, :string, default: nil + attr :logo_alt_text, :string, default: "" + attr :legal_text, :string, default: "© #{DateTime.utc_now().year} Your Company, Inc. All rights reserved." + + def footer(%{variant: "simple"} = assigns) do + ~H""" + + """ + end + + def footer(%{variant: "columns"} = assigns) do + ~H""" + + """ + end + + slot :footer_link do + attr :class, :string + end + + def column_items(assigns) do + ~H""" + + """ + end + + defp footer_link_base_classes do + ~w" + font-bold + text-4xl + [&_a]:rounded-lg + [&_a:hover]:underline + [&_a:hover]:underline-offset-8 + [&_a:focus-visible]:outline-none + [&_a:focus-visible]:ring-4 + [&_a:focus-visible]:ring-purple-500 + [&_a:focus-visible]:ring-offset-4 + [&_a:focus-visible]:ring-offset-[--sui-brand-secondary-bg] + " + end + + defp social_icons_base_classes do + ~w" + [&_a]:block + [&_a]:rounded-lg + [&_a:focus-visible]:outline-none + [&_a:focus-visible]:ring-4 + [&_a:focus-visible]:ring-purple-500 + [&_a:focus-visible]:ring-offset-4 + [&_a:focus-visible]:ring-offset-[--sui-brand-secondary-bg] + " + end +end diff --git a/sources/lib/station_ui/HTML/forms.ex b/sources/lib/station_ui/HTML/forms.ex new file mode 100644 index 0000000..07e0e46 --- /dev/null +++ b/sources/lib/station_ui/HTML/forms.ex @@ -0,0 +1,100 @@ +defmodule StationUI.HTML.Forms do + @moduledoc """ + This module exists to provide the same API as the Phoenix Core Components so as to support + generators that target the Core Components (`mix phx.gen.live`, `mix phx.gen.auth`, etc...) + """ + use Phoenix.Component + alias StationUI.HTML.Inputs + + attr :id, :any, default: nil + attr :name, :any + attr :label, :string, default: nil + attr :value, :any + + attr :type, :string, + default: "text", + values: ~w(checkbox color date datetime-local email file hidden month number password + range radio search select tel text textarea time url week) + + attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]" + + attr :errors, :list, default: [] + attr :checked, :boolean, doc: "the checked flag for checkbox inputs" + attr :prompt, :string, default: nil, doc: "the prompt for select inputs" + attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2" + attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs" + + attr :rest, :global, include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength + multiple pattern placeholder readonly required rows size step) + + slot :inner_block + + def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do + assigns + |> assign(field: nil, id: assigns.id || field.id) + |> assign(:errors, Enum.map(field.errors, &translate_error(&1))) + |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end) + |> assign_new(:value, fn -> field.value end) + |> input() + end + + def input(%{type: "checkbox"} = assigns) do + ~H""" + + <:label :if={@label}><%= @label %> + + """ + end + + def input(%{type: "select"} = assigns) do + ~H""" + + <:label :if={@label}><%= @label %> + + """ + end + + def input(%{type: "textarea"} = assigns) do + ~H""" + + <:label :if={@label}><%= @label %> + + """ + end + + def input(assigns) do + ~H""" + + <:label :if={@label}><%= @label %> + + """ + end + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # When using gettext, we typically pass the strings we want + # to translate as a static argument: + # + # # Translate the number of files with plural rules + # dngettext("errors", "1 file", "%{count} files", count) + # + # However the error messages in our forms and APIs are generated + # dynamically, so we need to translate them by calling Gettext + # with our gettext backend as first argument. Translations are + # available in the errors.po file (as we use the "errors" domain). + if count = opts[:count] do + Gettext.dngettext(StationUI.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(StationUI.Gettext, "errors", msg, opts) + end + end + + @doc """ + Translates the errors for a field from a keyword list of errors. + """ + def translate_errors(errors, field) when is_list(errors) do + for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) + end +end diff --git a/sources/lib/station_ui/HTML/icon_buttons.ex b/sources/lib/station_ui/HTML/icon_buttons.ex new file mode 100644 index 0000000..b265803 --- /dev/null +++ b/sources/lib/station_ui/HTML/icon_buttons.ex @@ -0,0 +1,144 @@ +defmodule StationUI.HTML.IconButtons do + use Phoenix.Component + + import StationUI.HTML.Icons, only: [icon: 1] + + @button_base [ + "bg-skin-btn text-skin-btn rounded-full border border-skin-btn whitespace-nowrap inline-flex justify-center items-center gap-x-1.5 lg:gap-x-2", + "hover:bg-skin-btn-hover active:bg-skin-btn-active hover:text-skin-btn-hover active:text-skin-btn-active hover:border-skin-btn-hover active:border-skin-btn-active focus-visible:ring-purple-500 focus-visible:ring-offset-4 focus-visible:ring-2 focus-visible:outline-none" + ] + + @destructive_classes "!border-skin-btn-destructive !bg-skin-btn-destructive text-skin-btn-destructive hover:!bg-skin-btn-destructive-hover hover:!border-skin-btn-destructive-hover hover:!text-skin-btn-destructive active:!bg-skin-btn-destructive-active active:!border-skin-btn-destructive-active active:!text-skin-btn-destructive" + + attr :rest, :global + attr :flavor, :string, default: "primary" + attr :destructive, :boolean, default: false + attr :size, :string, default: "md" + attr :responsive, :boolean, default: true + attr :pill, :boolean, default: false + attr :class, :string, default: nil + attr :icon, :string, required: true + attr :chevron, :boolean, default: nil + # We need to require one or the other of these to exist: + attr :text, :string, default: "" + attr :label, :string, default: "" + + def icon_btn(assigns) do + if assigns.text == "" and assigns.label == "", + do: raise("buttons without text require a label") + + ~H""" + + """ + end + + defp button_base, do: @button_base + + # Button flavor specific classes classes + + defp button_flavor("primary", destructive) do + if destructive, do: "btn-primary #{@destructive_classes}", else: "btn-primary" + end + + defp button_flavor("secondary", destructive) do + if destructive, do: "btn-secondary #{@destructive_classes}", else: "btn-secondary" + end + + defp button_flavor("tertiary", _), do: "btn-tertiary" + + # Size variant specific classes classes + # button_size(size, responsive, chevron, text) + + ## xl without label + defp button_size("xl", true, nil, ""), do: "lg:focus-visible:ring-4 p-[18px]" + defp button_size("xl", false, nil, ""), do: "text-base lg:focus-visible:ring-4 p-[18px]" + defp button_size("xl", true, _, ""), do: "lg:focus-visible:ring-4 py-[18px] pl-[22px] pr-[18px]" + + defp button_size("xl", false, _, ""), + do: "text-base lg:focus-visible:ring-4 py-[18px] pl-[22px] pr-[18px]" + + ## xl with label + defp button_size("xl", true, nil, _), do: "lg:focus-visible:ring-4 py-3 px-6" + defp button_size("xl", false, nil, _), do: "text-base lg:focus-visible:ring-4 py-3 px-6" + defp button_size("xl", true, _, _), do: "lg:focus-visible:ring-4 py-3 pl-7 pr-6" + defp button_size("xl", false, _, _), do: "text-base lg:focus-visible:ring-4 py-3 pl-7 pr-6" + + ## lg without label + defp button_size("lg", true, nil, ""), do: "text-base p-3.5" + defp button_size("lg", false, nil, ""), do: "text-base p-3.5" + defp button_size("lg", true, _, ""), do: "text-base py-3.5 pl-[18px] pr-3.5" + defp button_size("lg", false, _, ""), do: "text-base py-3.5 pl-[18px] pr-3.5" + + ## lg with label + defp button_size("lg", true, nil, _), do: "text-base py-2.5 px-5" + defp button_size("lg", false, nil, _), do: "text-base py-2.5 px-5" + defp button_size("lg", true, _, _), do: "text-base py-2.5 pl-6 pr-5" + defp button_size("lg", false, _, _), do: "text-base py-2.5 pl-6 pr-5" + + ## md without label + defp button_size("md", true, nil, ""), do: "text-sm p-2.5" + defp button_size("md", false, nil, ""), do: "text-sm p-2.5" + defp button_size("md", true, _, ""), do: "text-sm py-2.5 pl-3.5 pr-2.5" + defp button_size("md", false, _, ""), do: "text-sm py-2.5 pl-3.5 pr-2.5" + + ## md with label + defp button_size("md", true, nil, _), do: "text-sm py-2 px-3.5" + defp button_size("md", false, nil, _), do: "text-sm py-2 px-3.5" + defp button_size("md", true, _, _), do: "text-sm py-2 pl-[22px] pr-[18px]" + defp button_size("md", false, _, _), do: "text-sm py-2 pl-[22px] pr-[18px]" + + ## sm without label + defp button_size("sm", true, nil, ""), do: "text-xs p-2" + defp button_size("sm", false, nil, ""), do: "text-xs p-2" + defp button_size("sm", true, _, ""), do: "text-xs py-2 pl-3 pr-2" + defp button_size("sm", false, _, ""), do: "text-xs py-2 pl-3 pr-2" + + ## sm with label + defp button_size("sm", true, nil, _), do: "text-xs py-1.5 px-3" + defp button_size("sm", false, nil, _), do: "text-xs py-1.5 px-3" + defp button_size("sm", true, _, _), do: "text-xs py-1.5 pl-[18px] pr-3.5" + defp button_size("sm", false, _, _), do: "text-xs py-1.5 pl-[18px] pr-3.5" + + # Icon classes + + defp icon_classes("xl", true), do: "w-10 h-10 lg:w-12 lg:h-12" + defp icon_classes("xl", false), do: "w-12 h-12" + + defp icon_classes("lg", true), do: "w-8 h-8 lg:w-[38px] lg:h-[38px]" + defp icon_classes("lg", false), do: "w-[38px] h-[38px]" + + defp icon_classes("md", true), do: "w-6 h-6 lg:w-8 lg:h-8" + defp icon_classes("md", false), do: "w-8 h-8" + + defp icon_classes("sm", true), do: "w-6 h-6" + defp icon_classes("sm", false), do: "w-6 h-6" + + # Chevron classes + + defp chevron_classes("xl", true), do: "w-6 h-6 lg:w-7 lg:h-7" + defp chevron_classes("xl", false), do: "w-7 h-7" + + defp chevron_classes("lg", true), do: "w-6 h-6 lg:w-7 lg:h-7" + defp chevron_classes("lg", false), do: "w-7 h-7" + + defp chevron_classes("md", true), do: "w-5 h-5 lg:w-6 lg:h-6" + defp chevron_classes("md", false), do: "stroke stroke-white w-6 h-6" + + defp chevron_classes("sm", true), do: "w-4 h-4" + defp chevron_classes("sm", false), do: "w-4 h-4" +end diff --git a/sources/lib/station_ui/HTML/icons.ex b/sources/lib/station_ui/HTML/icons.ex new file mode 100644 index 0000000..4c54de5 --- /dev/null +++ b/sources/lib/station_ui/HTML/icons.ex @@ -0,0 +1,28 @@ +defmodule StationUI.HTML.Icons do + use Phoenix.Component + + @doc """ + Renders a [Heroicon](https://heroicons.com). + + Heroicons come in three styles – outline, solid, and mini. + By default, the outline style is used, but solid and mini may + be applied by using the `-solid` and `-mini` suffix. + + You can customize the size and colors of the icons by setting + width, height, and background color classes. + + ## Examples + + <.icon name="hero-x-mark-solid" /> + <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" /> + """ + attr(:name, :string, required: true) + attr(:class, :any, default: nil) + attr(:rest, :global) + + def icon(%{name: "hero-" <> _} = assigns) do + ~H""" +