├── .gitignore ├── .rspec ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── TODO.md ├── benchmarks ├── .keep └── table.txt ├── bin ├── bundle ├── console ├── rake ├── rspec ├── rubocop └── setup ├── examples ├── list.rb └── navbar.rb ├── lib ├── protos.rb └── protos │ ├── accordion.rb │ ├── accordion │ └── item.rb │ ├── alert.rb │ ├── alert │ ├── actions.rb │ └── icon.rb │ ├── attributes.rb │ ├── avatar.rb │ ├── badge.rb │ ├── breadcrumbs.rb │ ├── breadcrumbs │ └── crumb.rb │ ├── card.rb │ ├── card │ ├── actions.rb │ ├── body.rb │ ├── image.rb │ └── title.rb │ ├── carousel.rb │ ├── carousel │ ├── actions.rb │ └── item.rb │ ├── chat_bubble.rb │ ├── chat_bubble │ ├── content.rb │ ├── footer.rb │ ├── header.rb │ └── image.rb │ ├── collapse.rb │ ├── collapse │ ├── content.rb │ └── title.rb │ ├── combobox.rb │ ├── command.rb │ ├── command │ ├── empty.rb │ ├── group.rb │ ├── input.rb │ ├── item.rb │ ├── list.rb │ ├── title.rb │ └── trigger.rb │ ├── component.rb │ ├── diff.rb │ ├── diff │ ├── item.rb │ └── resizer.rb │ ├── drawer.rb │ ├── drawer │ ├── content.rb │ ├── side.rb │ └── trigger.rb │ ├── dropdown.rb │ ├── dropdown │ ├── item.rb │ ├── menu.rb │ └── trigger.rb │ ├── engine.rb │ ├── hero.rb │ ├── hero │ ├── content.rb │ └── overlay.rb │ ├── list.rb │ ├── list │ └── item.rb │ ├── menu.rb │ ├── menu │ └── item.rb │ ├── mix.rb │ ├── modal.rb │ ├── modal │ ├── close_button.rb │ ├── dialog.rb │ └── trigger.rb │ ├── popover.rb │ ├── popover │ ├── content.rb │ └── trigger.rb │ ├── stats.rb │ ├── stats │ ├── actions.rb │ ├── description.rb │ ├── figure.rb │ ├── stat.rb │ ├── title.rb │ └── value.rb │ ├── status.rb │ ├── steps.rb │ ├── steps │ └── step.rb │ ├── swap.rb │ ├── swap │ ├── off.rb │ └── on.rb │ ├── table.rb │ ├── table │ ├── body.rb │ ├── caption.rb │ ├── cell.rb │ ├── footer.rb │ ├── head.rb │ ├── header.rb │ └── row.rb │ ├── tabs.rb │ ├── tabs │ └── tab.rb │ ├── tailwind_merge.rb │ ├── theme.rb │ ├── timeline.rb │ ├── timeline │ ├── center.rb │ ├── item.rb │ ├── left.rb │ └── right.rb │ ├── toast.rb │ ├── toast │ └── close_button.rb │ ├── token_list.rb │ ├── types.rb │ ├── typography.rb │ ├── typography │ ├── heading.rb │ ├── inline_link.rb │ └── paragraph.rb │ └── version.rb ├── protos.gemspec ├── rakelib ├── benchmark.rake ├── memory.rake ├── profile.rake └── support │ ├── phlex_table.rb │ └── protos_table.rb └── spec ├── protos ├── accordion │ └── item_spec.rb ├── accordion_spec.rb ├── alert │ ├── actions_spec.rb │ └── icon_spec.rb ├── alert_spec.rb ├── attributes_spec.rb ├── avatar_spec.rb ├── badge_spec.rb ├── breadcrumbs │ └── crumb_spec.rb ├── breadcrumbs_spec.rb ├── card │ ├── actions_spec.rb │ ├── body_spec.rb │ ├── image_spec.rb │ └── title_spec.rb ├── card_spec.rb ├── carousel │ ├── actions_spec.rb │ └── item_spec.rb ├── carousel_spec.rb ├── chat_bubble │ ├── content_spec.rb │ ├── footer_spec.rb │ ├── header_spec.rb │ └── image_spec.rb ├── chat_bubble_spec.rb ├── collapse │ ├── content_spec.rb │ └── title_spec.rb ├── collapse_spec.rb ├── combobox_spec.rb ├── command │ ├── empty_spec.rb │ ├── group_spec.rb │ ├── input_spec.rb │ ├── item_spec.rb │ ├── list_spec.rb │ ├── title_spec.rb │ └── trigger_spec.rb ├── command_spec.rb ├── component_spec.rb ├── diff │ ├── item_spec.rb │ └── resizer_spec.rb ├── diff_spec.rb ├── drawer │ ├── content_spec.rb │ ├── side_spec.rb │ └── trigger_spec.rb ├── drawer_spec.rb ├── dropdown │ ├── item_spec.rb │ ├── menu_spec.rb │ └── trigger_spec.rb ├── dropdown_spec.rb ├── hero │ ├── content_spec.rb │ └── overlay_spec.rb ├── hero_spec.rb ├── list │ └── item_spec.rb ├── list_spec.rb ├── menu_spec.rb ├── mix_spec.rb ├── modal │ ├── close_button_spec.rb │ ├── dialog_spec.rb │ └── trigger_spec.rb ├── modal_spec.rb ├── popover │ ├── content_spec.rb │ └── trigger_spec.rb ├── popover_spec.rb ├── stats │ ├── actions_spec.rb │ ├── description_spec.rb │ ├── figure_spec.rb │ ├── stat_spec.rb │ ├── title_spec.rb │ └── value_spec.rb ├── status_spec.rb ├── steps │ └── step_spec.rb ├── steps_spec.rb ├── swap │ ├── off_spec.rb │ └── on_spec.rb ├── swap_spec.rb ├── table │ ├── body_spec.rb │ ├── caption_spec.rb │ ├── cell_spec.rb │ ├── footer_spec.rb │ ├── head_spec.rb │ ├── header_spec.rb │ └── row_spec.rb ├── table_spec.rb ├── tabs │ └── tab_spec.rb ├── tabs_spec.rb ├── tailwind_merge_spec.rb ├── theme_spec.rb ├── timeline │ ├── center_spec.rb │ ├── item_spec.rb │ ├── left_spec.rb │ └── right_spec.rb ├── timeline_spec.rb ├── toast │ └── close_button_spec.rb ├── toast_spec.rb ├── token_list_spec.rb ├── typography │ ├── heading_spec.rb │ ├── inline_link_spec.rb │ └── paragraph_spec.rb └── typography_spec.rb ├── protos_spec.rb ├── spec_helper.rb └── support ├── capybara.rb ├── matchers.rb └── phlex.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | 13 | # https://yehudakatz.com/2010/12/16/clarifying-the-roles-of-the-gemspec-and-gemfile/ 14 | Gemfile.lock 15 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-inhouse 3 | 4 | inherit_gem: 5 | rubocop-inhouse: 6 | - config/default.yml 7 | 8 | AllCops: 9 | TargetRubyVersion: 3.2 10 | 11 | Naming/BlockForwarding: 12 | EnforcedStyle: anonymous 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [1.0.1] - 2025-03-01 4 | 5 | - Fixes a bug with inherited classes not finding their defined keys 6 | 7 | ## [1.0.0] - 2025-03-01 8 | 9 | - Adds `Protos::Status` component 10 | - Adds `Protos::Diff` component 11 | - Adds `Protos::Menu` component 12 | - Adds `state` and `icon` options to `Protos::Collapse` 13 | - Changes `dash` option to `dashed` on `Protos::Card` 14 | - Adds `dashed` and `soft` to `Protos::Badge` 15 | - Adds `xl` size to `Protos::Badge` 16 | - Updates to Phlex `v2` 17 | - Updates to new DaisyUI `v5.0` styles 18 | - Performance improvements to `Protos::Theme` 19 | - Removes redefining `default_attrs` and `theme` methods. Didn't want the 20 | overhead of this completely unused feature. This removes `dry-core` as 21 | a dependency 22 | 23 | ## [0.7.0] - 2025-01-13 24 | 25 | - Changes passing an `input_id` to accordions. Replaced with the more accurate 26 | `input_name` (optional) parameter. There was no point in having different 27 | name attributes for the different radio buttons 28 | - Updates modal component to use newer modal controller from protos-stimulus 29 | that uses `@stimulus-components/dialog` 30 | - Adds the ability for `css` helper to take multiple values, including inline 31 | values instead of theme keys. 32 | - Adds new `Protos::Badge` component 33 | - Removes deprecated tokens to prepare for phlex 2.0 34 | - Removes `dry-initializer` undefined constant to improve performance 35 | - Adds autoloading constants instead of hard requires 36 | 37 | ## [0.6.0] - 2024-09-04 38 | 39 | - Changes how merging attributes works to only mix on certain attributes, 40 | overriding on all others. This is opposite to how attributes used to be merged 41 | by default. This is a fix for attributes like `value` where you actually need 42 | to override them. 43 | - Adds a separate tested `Mix` class for handling attribute merging 44 | 45 | ## [0.5.0] - 2024-08-27 46 | 47 | - Fixes all accessibility violations according to Axe Core 48 | - Reduces responsibility of Tabs to only be a tablist, no tab panels 49 | - Fixes passing ID to popovers, dropdowns, drawers, etc to not override the 50 | input ID 51 | - Changes trigger on popover to be a button instead of a div 52 | 53 | ## [0.4.3] - 2024-08-14 54 | 55 | - Removes unneeded auto-loading in Rails which fixes collisions with `protos-markdown` 56 | - Adds fixes for handling form submissions within modals with `protos-stimulus` 57 | v0.0.3 58 | - Adds ability to disable margin on p tags with `Protos::Typography` 59 | 60 | ## [0.4.2] - 2024-04-30 61 | 62 | - Adds ability to pass arrays of tokens to themes 63 | - Removes unnecessary calls to `tokens`, preferring arrays of strings for 64 | performance 65 | - Improvements to performance with constant hash lookups and early returns on 66 | `mix` 67 | - Adds steps component 68 | 69 | ## [0.4.0] - 2024-04-09 70 | 71 | - Phlex discord didn't like all the calls to `render` 72 | - Apparently `render` is called differently within ERB 73 | - Suggested to change all calls to immediately render, would improve ergonomics 74 | 75 | ## [0.3.0] - 2024-04-06 76 | 77 | - Updates to phlex v0.10 by changing all `template` methods to `view_template` 78 | - Improvements to README 79 | 80 | ## [0.2.0] - 2024-03-06 81 | 82 | - Chose to move away from daisyUI dropdown defaults as they have problems 83 | displaying properly in too many situations 84 | - Opted for using a protos-stimulus controller that uses tippy.js 85 | 86 | ## [0.1.0] - 2024-03-01 87 | 88 | - Initial release 89 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in protos.gemspec 6 | gemspec 7 | 8 | gem "benchmark" 9 | gem "benchmark-ips" 10 | gem "benchmark-memory" 11 | gem "capybara" 12 | gem "debug", platforms: %i[mri mingw x64_mingw], require: "debug/prelude" 13 | gem "memory_profiler" 14 | gem "phlex-testing-capybara" 15 | gem "rake" 16 | gem "rspec" 17 | gem "rubocop-inhouse", require: false 18 | gem "ruby-prof" 19 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Nolan J Tait 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | require "rubocop/rake_task" 9 | 10 | RuboCop::RakeTask.new 11 | 12 | task default: %i[spec rubocop] 13 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # UI Components 2 | 3 | - [ ] Empty state 4 | - [ ] Steps 5 | - [ ] Avatar group 6 | - [ ] Calendar 7 | - [ ] [Descriptions](https://ant.design/components/descriptions) 8 | - [ ] Image (progressive loading, fault tollerant) 9 | 10 | # Form inputs 11 | 12 | - [ ] [Cascader](https://ant.design/components/cascader) 13 | - [ ] Rating 14 | - [ ] Select (multiselect, combobox, etc) 15 | - [ ] Time picker 16 | - [ ] [Transfer](https://ant.design/components/transfer) 17 | - [ ] Upload 18 | 19 | -------------------------------------------------------------------------------- /benchmarks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inhouse-work/protos/a04d4eb35aa2388bd28ed8f931832e06a8d912fb/benchmarks/.keep -------------------------------------------------------------------------------- /benchmarks/table.txt: -------------------------------------------------------------------------------- 1 | ruby 3.3.0 (2023-12-25 revision 5124f9ac75) [arm64-darwin23] 2 | Warming up -------------------------------------- 3 | Protos::Table 41.000 i/100ms 4 | Phlex::Table 1.239k i/100ms 5 | Calculating ------------------------------------- 6 | Protos::Table 417.028 (± 0.2%) i/s - 2.091k in 5.014103s 7 | Phlex::Table 12.785k (± 0.6%) i/s - 64.428k in 5.039702s 8 | 9 | Comparison: 10 | Phlex::Table: 12784.6 i/s 11 | Protos::Table: 417.0 i/s - 30.66x slower 12 | 13 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a.match?(Gem::Version::ANCHORED_VERSION_PATTERN) 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../Gemfile", __dir__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, ".locked") 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_requirement 64 | @bundler_requirement ||= 65 | env_var_version || 66 | cli_arg_version || 67 | bundler_requirement_for(lockfile_version) 68 | end 69 | 70 | def bundler_requirement_for(version) 71 | return "#{Gem::Requirement.default}.a" unless version 72 | 73 | bundler_gem_version = Gem::Version.new(version) 74 | 75 | bundler_gem_version.approximate_recommendation 76 | end 77 | 78 | def load_bundler! 79 | ENV["BUNDLE_GEMFILE"] ||= gemfile 80 | 81 | activate_bundler 82 | end 83 | 84 | def activate_bundler 85 | gem_error = activation_error_handling do 86 | gem "bundler", bundler_requirement 87 | end 88 | return if gem_error.nil? 89 | require_error = activation_error_handling do 90 | require "bundler/version" 91 | end 92 | return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 93 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" 94 | exit 42 95 | end 96 | 97 | def activation_error_handling 98 | yield 99 | nil 100 | rescue StandardError, LoadError => e 101 | e 102 | end 103 | end 104 | 105 | m.load_bundler! 106 | 107 | if m.invoked_as_script? 108 | load Gem.bin_path("bundler", "bundle") 109 | end 110 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "protos" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require "irb" 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rake", "rake") 28 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rspec-core", "rspec") 28 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rubocop' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rubocop", "rubocop") 28 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /examples/list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "protos" 4 | 5 | class List < Protos::Component 6 | def view_template 7 | ul(**attrs) do 8 | li(class: css[:item]) { "Item 1" } 9 | li(class: css[:item]) { "Item 2" } 10 | end 11 | end 12 | 13 | def default_attrs 14 | { 15 | data: { 16 | controller: "list" 17 | } 18 | } 19 | end 20 | 21 | def theme 22 | { 23 | container: "space-y-4", 24 | item: "font-bold" 25 | } 26 | end 27 | end 28 | 29 | list = List.new( 30 | theme: { 31 | container: "space-y-8", 32 | item: "bg-red-500" 33 | } 34 | ) 35 | 36 | puts list.call 37 | -------------------------------------------------------------------------------- /examples/navbar.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../lib/protos" 4 | 5 | class Navbar < Protos::Component 6 | def view_template 7 | header(**attrs) do 8 | h1(class: css[:heading]) { "Hello world" } 9 | h2(class: css[:subtitle]) { "With a subtitle" } 10 | end 11 | end 12 | 13 | private 14 | 15 | def default_attrs 16 | { 17 | data: { controller: "navbar" } 18 | } 19 | end 20 | 21 | def theme 22 | { 23 | container: "flex justify-between items-center gap-sm", 24 | heading: "text-2xl font-bold", 25 | subtitle: "text-base" 26 | } 27 | end 28 | end 29 | 30 | component = Navbar.new( 31 | class: "my-sm", 32 | data: { controller: "counter" }, 33 | theme: { 34 | heading: "p-sm", # We can add tokens 35 | "!container": "gap-sm", # We can negate (remove) certain tokens 36 | subtitle!: "text-xl" # We can override the entire slot 37 | } 38 | ) 39 | 40 | puts component.call 41 | -------------------------------------------------------------------------------- /lib/protos.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | require "dry-types" 5 | require "dry-initializer" 6 | require "phlex" 7 | require "tailwind_merge" 8 | 9 | module Protos 10 | class Error < StandardError; end 11 | 12 | autoload :Version, "protos/version" 13 | autoload :Types, "protos/types" 14 | autoload :TokenList, "protos/token_list" 15 | autoload :Component, "protos/component" 16 | autoload :Theme, "protos/theme" 17 | autoload :Mix, "protos/mix" 18 | autoload :Attributes, "protos/attributes" 19 | autoload :TailwindMerge, "protos/tailwind_merge" 20 | 21 | # Components 22 | autoload :Accordion, "protos/accordion" 23 | autoload :Alert, "protos/alert" 24 | autoload :Avatar, "protos/avatar" 25 | autoload :Badge, "protos/badge" 26 | autoload :Breadcrumbs, "protos/breadcrumbs" 27 | autoload :Card, "protos/card" 28 | autoload :Carousel, "protos/carousel" 29 | autoload :ChatBubble, "protos/chat_bubble" 30 | autoload :Collapse, "protos/collapse" 31 | autoload :Command, "protos/command" 32 | autoload :Diff, "protos/diff" 33 | autoload :Drawer, "protos/drawer" 34 | autoload :Hero, "protos/hero" 35 | autoload :List, "protos/list" 36 | autoload :Menu, "protos/menu" 37 | autoload :Modal, "protos/modal" 38 | autoload :Popover, "protos/popover" 39 | autoload :Stats, "protos/stats" 40 | autoload :Steps, "protos/steps" 41 | autoload :Status, "protos/status" 42 | autoload :Swap, "protos/swap" 43 | autoload :Tabs, "protos/tabs" 44 | autoload :Table, "protos/table" 45 | autoload :Toast, "protos/toast" 46 | autoload :Timeline, "protos/timeline" 47 | autoload :Typography, "protos/typography" 48 | 49 | # Dependent 50 | autoload :Dropdown, "protos/dropdown" 51 | autoload :Combobox, "protos/combobox" 52 | end 53 | 54 | require_relative "protos/engine" if defined?(Rails) 55 | -------------------------------------------------------------------------------- /lib/protos/accordion.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Accordion < Component 5 | # DOCS: The accordion component. Accordion is a collapse with radio buttons. 6 | # Only one item can be open at a time. If you want to allow multiple items 7 | # to be open at the same time, use the collapse component. 8 | # https://daisyui.com/components/accordion/ 9 | 10 | autoload :Item, "protos/accordion/item" 11 | 12 | option :input_name, 13 | default: -> { "accordion-#{SecureRandom.hex(4)}" }, 14 | reader: false, 15 | type: Types::String 16 | 17 | def view_template(&) 18 | ul(**attrs, &) 19 | end 20 | 21 | def item(*, **, &) 22 | render Item.new(*, input_name: @input_name, **, &) 23 | end 24 | 25 | def content(...) = render Collapse::Content.new(...) 26 | 27 | def title(*, **, &) 28 | render Collapse::Title.new(*, input_id: @input_name, **, &) 29 | end 30 | 31 | private 32 | 33 | def theme 34 | { 35 | container: "join join-vertical" 36 | } 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/protos/accordion/item.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Accordion 5 | class Item < Component 6 | # DOCS: An accorion is just a collapse with radio buttons. 7 | 8 | option :input_name, 9 | type: Types::String | Types::Integer | Types::Nil 10 | option :input_type, 11 | type: Collapse::InputTypes, 12 | default: -> { :radio } 13 | 14 | def view_template(&block) 15 | li(**attrs) do 16 | render Collapse.new( 17 | theme: collapse_theme, 18 | input_name:, 19 | input_type:, 20 | &block 21 | ) 22 | end 23 | end 24 | 25 | private 26 | 27 | def collapse_theme 28 | { "!container": "bg-base-100" }.tap do |theme| 29 | theme[:container!] = css[:collapse] if css[:collapse] 30 | end 31 | end 32 | 33 | def theme 34 | { 35 | container: "join-item" 36 | } 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/protos/alert.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Alert < Component 5 | # DOCS: A component that displays messages (usually from flashes). These can 6 | # be used in combination with Protos::Toast to have popup notifications. 7 | # https://daisyui.com/components/alert/ 8 | 9 | autoload :Actions, "protos/alert/actions" 10 | autoload :Icon, "protos/alert/icon" 11 | 12 | Styles = Types::Coercible::Symbol.enum( 13 | :info, 14 | :success, 15 | :warning, 16 | :error 17 | ) 18 | 19 | STYLES = { 20 | info: "alert-info", 21 | error: "alert-error", 22 | warning: "alert-warning", 23 | success: "alert-success" 24 | }.freeze 25 | 26 | option :type, type: Styles, default: -> { :info }, reader: false 27 | 28 | def view_template(&) 29 | div(**attrs, &) 30 | end 31 | 32 | def icon(...) = render Icon.new(...) 33 | 34 | def actions(...) = render Actions.new(...) 35 | 36 | private 37 | 38 | def theme 39 | { 40 | container: ["alert", style] 41 | } 42 | end 43 | 44 | def style 45 | STYLES.fetch(@type) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/protos/alert/actions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Alert 5 | class Actions < Component 6 | # DOCS: Area for actions (e.g buttons) within an alert 7 | 8 | def view_template(&) 9 | nav(**attrs, &) 10 | end 11 | 12 | private 13 | 14 | def default_attrs 15 | { 16 | aria_label: "alert-actions" 17 | } 18 | end 19 | 20 | def theme 21 | { 22 | container: "flex gap-xs items-center" 23 | } 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/protos/alert/icon.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Alert 5 | class Icon < Component 6 | # DOCS: Icon for the alert, usually showing at the top left corner aligned 7 | # to the text 8 | 9 | def view_template(&) 10 | div(**attrs, &) 11 | end 12 | 13 | private 14 | 15 | def theme 16 | { 17 | container: "place-self-start mt-1" 18 | } 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/protos/attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Attributes 5 | # DOCS: A class that represents the attributes of a component. This would be 6 | # all html options except for `class` and `theme`. 7 | 8 | def initialize(attrs = {}, **kwargs) 9 | @attrs = attrs.merge!(kwargs) 10 | end 11 | 12 | def [](key) 13 | @attrs[key] 14 | end 15 | 16 | def merge(hash) 17 | return self unless hash 18 | 19 | @attrs = mix(@attrs, hash) 20 | self 21 | end 22 | 23 | # Allows for the use of the `**` operator to pass the attributes to 24 | # a method. 25 | def to_hash 26 | @attrs 27 | end 28 | 29 | private 30 | 31 | def mix(hash, *hashes) 32 | Mix.call(hash, *hashes) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/protos/avatar.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Avatar < Component 5 | # DOCS: The avatar component is used to represent a user or entity. 6 | # https://daisyui.com/components/avatar/ 7 | 8 | Indicators = Types::Coercible::Symbol.enum(:none, :online, :offline) 9 | MaskShapes = Types::Coercible::Symbol.enum( 10 | :none, 11 | :squircle, 12 | :heart, 13 | :hexagon, 14 | :hexagon2, 15 | :decagon, 16 | :pentagon, 17 | :diamond, 18 | :square, 19 | :circle, 20 | :star, 21 | :star2, 22 | :triangle, 23 | :triangle2, 24 | :triangle3, 25 | :triangle4, 26 | :half1, 27 | :half2 28 | ) 29 | 30 | SHAPES = { 31 | none: "", 32 | squircle: "mask mask-squircle", 33 | heart: "mask mask-heart", 34 | hexagon: "mask mask-hexagon", 35 | hexagon2: "mask mask-hexagon-2", 36 | decagon: "mask mask-decagon", 37 | pentagon: "mask mask-pentagon", 38 | diamond: "mask mask-diamond", 39 | square: "mask mask-square", 40 | circle: "mask mask-circle", 41 | star: "mask mask-star", 42 | star2: "mask mask-star-2", 43 | triangle: "mask mask-triangle", 44 | triangle2: "mask mask-triangle-2", 45 | triangle3: "mask mask-triangle-3", 46 | triangle4: "mask mask-triangle-4", 47 | half1: "mask mask-half-1", 48 | half2: "mask mask-half-2" 49 | }.freeze 50 | 51 | INDICATORS = { 52 | none: "", 53 | online: "avatar-online", 54 | offline: "avatar-offline" 55 | }.freeze 56 | 57 | option :placeholder, type: Types::Bool, default: -> { false } 58 | option :indicator, 59 | type: Indicators, 60 | default: -> { :none }, 61 | reader: false 62 | option :shape, 63 | type: MaskShapes, 64 | default: -> { :none }, 65 | reader: false 66 | 67 | def view_template(&block) 68 | div(**attrs) do 69 | div(class: css[:figure], &block) 70 | end 71 | end 72 | 73 | private 74 | 75 | def indicator 76 | INDICATORS.fetch(@indicator) 77 | end 78 | 79 | def shape 80 | SHAPES.fetch(@shape) 81 | end 82 | 83 | def theme 84 | { 85 | container: [ 86 | "avatar", 87 | indicator, 88 | ("placeholder" if placeholder) 89 | ], 90 | figure: shape 91 | } 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/protos/badge.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Badge < Component 5 | Styles = Types::Coercible::Symbol.enum( 6 | *Types::Styles.values, 7 | :ghost 8 | ) 9 | 10 | Sizes = Types::Coercible::Symbol.enum(:default, :xs, :sm, :md, :lg, :xl) 11 | 12 | STYLES = { 13 | default: "", 14 | neutral: "badge-neutral", 15 | success: "badge-success", 16 | primary: "badge-primary", 17 | secondary: "badge-secondary", 18 | accent: "badge-accent", 19 | info: "badge-info", 20 | error: "badge-error", 21 | warning: "badge-warning", 22 | ghost: "badge-ghost" 23 | }.freeze 24 | 25 | SIZES = { 26 | default: "badge-md", 27 | xs: "badge-xs", 28 | sm: "badge-sm", 29 | md: "badge-md", 30 | lg: "badge-lg", 31 | xl: "badge-xl" 32 | }.freeze 33 | 34 | option :type, type: Styles, default: -> { :default } 35 | option :outline, default: -> { false } 36 | option :dashed, default: -> { false } 37 | option :soft, default: -> { false } 38 | option :size, type: Sizes, default: -> { :default } 39 | 40 | def view_template(&) 41 | span(**attrs, &) 42 | end 43 | 44 | private 45 | 46 | def theme 47 | { 48 | container: [ 49 | "badge", 50 | STYLES.fetch(type), 51 | SIZES.fetch(size), 52 | ("badge-outline" if outline), 53 | ("badge-dashed" if dashed), 54 | ("badge-soft" if soft) 55 | ].compact 56 | } 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/protos/breadcrumbs.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Breadcrumbs < Component 5 | # DOCS: A list of breadcrumbs (e.g links) for navigation 6 | # https://daisyui.com/components/breadcrumbs/ 7 | 8 | autoload :Crumb, "protos/breadcrumbs/crumb" 9 | 10 | def view_template(&block) 11 | nav(**attrs) do 12 | ul(class: css[:list], &block) 13 | end 14 | end 15 | 16 | def crumb(...) = render Crumb.new(...) 17 | 18 | private 19 | 20 | def default_attrs 21 | { 22 | aria_label: "breadcrumbs" 23 | } 24 | end 25 | 26 | def theme 27 | { 28 | container: "breadcrumbs" 29 | } 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/protos/breadcrumbs/crumb.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Breadcrumbs 5 | class Crumb < Component 6 | # DOCS: A crumb is a single item within a breadcrumb 7 | 8 | def view_template(&) 9 | li(**attrs, &) 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/protos/card.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Card < Component 5 | # DOCS: A card component 6 | # https://daisyui.com/components/card/ 7 | 8 | autoload :Body, "protos/card/body" 9 | autoload :Title, "protos/card/title" 10 | autoload :Actions, "protos/card/actions" 11 | autoload :Image, "protos/card/image" 12 | 13 | Sizes = Types::Coercible::Symbol.enum( 14 | :default, 15 | :xs, 16 | :sm, 17 | :md, 18 | :lg, 19 | :xl 20 | ) 21 | 22 | SIZES = { 23 | default: "card-md", 24 | xs: "card-xs", 25 | sm: "card-sm", 26 | md: "card-md", 27 | lg: "card-lg", 28 | xl: "card-xl" 29 | }.freeze 30 | 31 | option :size, type: Sizes, default: -> { :default }, reader: :private 32 | option :image_side, 33 | type: Types::Bool, 34 | default: -> { false }, 35 | reader: :private 36 | option :image_full, 37 | type: Types::Bool, 38 | default: -> { false }, 39 | reader: :private 40 | option :border, type: Types::Bool, default: -> { true }, reader: :private 41 | option :dashed, type: Types::Bool, default: -> { false }, reader: :private 42 | 43 | def view_template(&) 44 | article(**attrs, &) 45 | end 46 | 47 | def body(...) = render Body.new(...) 48 | 49 | def image(...) = render Image.new(...) 50 | 51 | def title(...) = render Title.new(...) 52 | 53 | def actions(...) = render Actions.new(...) 54 | 55 | private 56 | 57 | def theme 58 | { 59 | container: [ 60 | "card", 61 | SIZES[size], 62 | ("card-border" if border), 63 | ("card-dash" if dashed), 64 | ("image-full" if image_full), 65 | ("card-side" if image_side) 66 | ] 67 | } 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/protos/card/actions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Card 5 | class Actions < Component 6 | # DOCS: Area for actions (e.g buttons) within a card 7 | 8 | def view_template(&) 9 | nav(**attrs, &) 10 | end 11 | 12 | private 13 | 14 | def theme 15 | { 16 | container: "card-actions" 17 | } 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/protos/card/body.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Card 5 | class Body < Component 6 | # DOCS: The main content area of a card 7 | 8 | def view_template(&) 9 | div(**attrs, &) 10 | end 11 | 12 | private 13 | 14 | def theme 15 | { 16 | container: "card-body" 17 | } 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/protos/card/image.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Card 5 | class Image < Component 6 | # DOCS: A container for an image for on a card. This matches the examples 7 | # on daisyui which will style the
tag depending on whether 8 | # image-overlay is present on the card. 9 | 10 | def view_template(&) 11 | figure(**attrs, &) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/protos/card/title.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Card 5 | class Title < Component 6 | # DOCS: The title of a card 7 | 8 | def view_template(&) 9 | div(**attrs, &) 10 | end 11 | 12 | private 13 | 14 | def theme 15 | { 16 | container: "card-title" 17 | } 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/protos/carousel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Carousel < Component 5 | # DOCS: A carousel component that contains items that can be scrolled 6 | # through in a mobile friendly manner. 7 | # https://daisyui.com/components/carousel/ 8 | 9 | autoload :Item, "protos/carousel/item" 10 | autoload :Actions, "protos/carousel/actions" 11 | 12 | Positions = Types::Coercible::Symbol.enum( 13 | :none, 14 | :center, 15 | :end 16 | ) 17 | 18 | SNAP_POINTS = { 19 | none: "", 20 | center: "carousel-center", 21 | end: "carousel-end" 22 | }.freeze 23 | 24 | option :vertical, type: Types::Bool, default: -> { false } 25 | option :snap_to, 26 | default: -> { :none }, 27 | reader: false, 28 | type: Positions 29 | 30 | def view_template(&) 31 | div(**attrs, &) 32 | end 33 | 34 | def item(...) = render Item.new(...) 35 | 36 | def actions(...) = render Actions.new(...) 37 | 38 | private 39 | 40 | def snap_to 41 | SNAP_POINTS.fetch(@snap_to) 42 | end 43 | 44 | def theme 45 | { 46 | container: [ 47 | "carousel", 48 | snap_to, 49 | ("carousel-vertical" if vertical) 50 | ] 51 | } 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/protos/carousel/actions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Carousel 5 | class Actions < Component 6 | # DOCS: Area for actions (e.g buttons) within a carousel 7 | 8 | def view_template(&) 9 | div(**attrs, &) 10 | end 11 | 12 | private 13 | 14 | def theme 15 | { 16 | container: %w[ 17 | absolute 18 | flex 19 | justify-between 20 | transform 21 | -translate-y-1/2 22 | left-sm 23 | right-sm 24 | top-1/2 25 | ] 26 | } 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/protos/carousel/item.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Carousel 5 | class Item < Component 6 | # DOCS: A single item within a carousel 7 | 8 | def view_template(&) 9 | div(**attrs, &) 10 | end 11 | 12 | private 13 | 14 | def theme 15 | { 16 | container: "carousel-item" 17 | } 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/protos/chat_bubble.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class ChatBubble < Component 5 | # DOCS: A chat bubble component that contains a message an possibly some 6 | # metadata and an image. Each chat bubble would represent a single message 7 | # in a larger chat history. 8 | # https://daisyui.com/components/chat/ 9 | 10 | autoload :Content, "protos/chat_bubble/content" 11 | autoload :Image, "protos/chat_bubble/image" 12 | autoload :Header, "protos/chat_bubble/header" 13 | autoload :Footer, "protos/chat_bubble/footer" 14 | 15 | Positions = Types::Coercible::Symbol.enum( 16 | :start, 17 | :end 18 | ) 19 | 20 | ALIGNMENTS = { 21 | start: "chat-start", 22 | end: "chat-end" 23 | }.freeze 24 | 25 | option :align, 26 | default: -> { :start }, 27 | reader: false, 28 | type: Positions 29 | 30 | def view_template(&) 31 | div(**attrs, &) 32 | end 33 | 34 | def content(...) = render Content.new(...) 35 | 36 | def footer(...) = render Footer.new(...) 37 | 38 | def header(...) = render Header.new(...) 39 | 40 | def image(...) = render Image.new(...) 41 | 42 | private 43 | 44 | def align 45 | ALIGNMENTS.fetch(@align) 46 | end 47 | 48 | def theme 49 | { 50 | container: %W[ 51 | chat 52 | #{align} 53 | ] 54 | } 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/protos/chat_bubble/content.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class ChatBubble 5 | class Content < Component 6 | # DOCS: The main content of a chat bubble. This would normally be the text 7 | # content of the message. It will be colored according to the type. 8 | 9 | STYLES = { 10 | default: "", 11 | accent: "chat-bubble-accent", 12 | error: "chat-bubble-error", 13 | info: "chat-bubble-info", 14 | neutral: "chat-bubble-neutral", 15 | primary: "chat-bubble-primary", 16 | secondary: "chat-bubble-secondary", 17 | success: "chat-bubble-success", 18 | warning: "chat-bubble-warning" 19 | }.freeze 20 | 21 | option :type, 22 | default: -> { :default }, 23 | reader: false, 24 | type: Types::Styles 25 | 26 | def view_template(&) 27 | div(**attrs, &) 28 | end 29 | 30 | private 31 | 32 | def style 33 | STYLES.fetch(@type) 34 | end 35 | 36 | def theme 37 | { 38 | container: %W[ 39 | chat-bubble 40 | #{style} 41 | ] 42 | } 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/protos/chat_bubble/footer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class ChatBubble 5 | class Footer < Component 6 | # DOCS: The footer of a chat bubble 7 | 8 | def view_template(&) 9 | div(**attrs, &) 10 | end 11 | 12 | private 13 | 14 | def theme 15 | { 16 | container: "chat-footer" 17 | } 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/protos/chat_bubble/header.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class ChatBubble 5 | class Header < Component 6 | # DOCS: The header of a chat bubble. This is typically used to display the 7 | # name of the user who sent the message. 8 | 9 | def view_template(&) 10 | div(**attrs, &) 11 | end 12 | 13 | private 14 | 15 | def theme 16 | { 17 | container: "chat-header" 18 | } 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/protos/chat_bubble/image.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class ChatBubble 5 | class Image < Component 6 | # DOCS: An image within a chat bubble. This would typically be an avatar 7 | # component. 8 | 9 | def view_template(&) 10 | div(**attrs, &) 11 | end 12 | 13 | private 14 | 15 | def theme 16 | { 17 | container: "chat-image" 18 | } 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/protos/collapse.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Collapse < Component 5 | # DOCS: A collapsible container that can be expanded or collapsed. The title 6 | # is visible at all times, and the content is only visible when expanded. 7 | # https://daisyui.com/components/collapse/ 8 | 9 | autoload :Title, "protos/collapse/title" 10 | autoload :Content, "protos/collapse/content" 11 | 12 | Icons = Types::Coercible::Symbol.enum(:arrow, :plus) 13 | 14 | ICONS = { 15 | arrow: "collapse-arrow", 16 | plus: "collapse-plus" 17 | }.freeze 18 | 19 | States = Types::Coercible::Symbol.enum(:default, :open, :close) 20 | 21 | STATES = { 22 | default: "", 23 | open: "collapse-open", 24 | close: "collapse-close" 25 | }.freeze 26 | 27 | InputTypes = Types::Coercible::Symbol.enum( 28 | :radio, 29 | :checkbox 30 | ) 31 | 32 | option :state, type: States, default: -> { :default }, reader: false 33 | option :icon, type: Icons, default: -> { :arrow }, reader: false 34 | option :input_type, 35 | type: InputTypes, 36 | default: -> { :checkbox }, 37 | reader: false 38 | option :input_name, 39 | reader: false, 40 | default: -> { "collapse-#{SecureRandom.hex(4)}" }, 41 | type: Types::String | Types::Integer 42 | 43 | def view_template 44 | div(**attrs) do 45 | if @input_type 46 | input( 47 | type: @input_type, 48 | id: @input_name, 49 | name: @input_name, 50 | autocomplete: :off, 51 | aria_label: "Toggle accordion", 52 | # form: "" prevents the radio button from being submitted if its 53 | # within a form 54 | form: "" 55 | ) 56 | end 57 | yield if block_given? 58 | end 59 | end 60 | 61 | def title(*, **, &) = render Title.new(*, input_id: @input_name, **, &) 62 | 63 | def content(...) = render Content.new(...) 64 | 65 | private 66 | 67 | def theme 68 | { 69 | container: [ 70 | "collapse", 71 | ICONS.fetch(@icon), 72 | STATES.fetch(@state) 73 | ] 74 | } 75 | end 76 | 77 | def default_attrs 78 | { 79 | tabindex: 0 80 | } 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/protos/collapse/content.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Collapse 5 | class Content < Component 6 | # DOCS: The content of a collapse. This would be hidden until the collapse 7 | # is opened. 8 | 9 | def view_template(&) 10 | div(**attrs, &) 11 | end 12 | 13 | private 14 | 15 | def theme 16 | { 17 | container: "collapse-content" 18 | } 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/protos/collapse/title.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Collapse 5 | class Title < Component 6 | # DOCS: The title of a collapse. This is the content that is always 7 | # visible and is used to toggle the collapse. 8 | 9 | option :input_id, 10 | type: Types::String | Types::Integer | Types::Nil, 11 | reader: false, 12 | default: -> { } 13 | 14 | def view_template(&) 15 | if @input_id 16 | label(for: @input_id.to_s, **attrs, &) 17 | else 18 | div(**attrs, &) 19 | end 20 | end 21 | 22 | private 23 | 24 | def theme 25 | { 26 | container: "collapse-title" 27 | } 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/protos/combobox.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Combobox < Popover 5 | # DOCS: A combobox is a combination of a text input and a list of options. 6 | # It makes selecting values from a large list easier by filtering the list. 7 | # Comboboxes use popovers and command to create the list of options. 8 | 9 | option :trigger, 10 | default: -> { :click }, 11 | reader: false, 12 | type: Popover::Triggers | Types::Array.of(Popover::Triggers) 13 | 14 | def trigger(...) = render Popover::Trigger.new(...) 15 | 16 | def content(...) = render Popover::Content.new(...) 17 | 18 | def input(...) = render Command::Input.new(...) 19 | 20 | def group(...) = render Command::Group.new(...) 21 | 22 | def item(...) = render Command::Item.new(...) 23 | 24 | def list(...) = render Command::List.new(...) 25 | 26 | def title(...) = render Command::Title.new(...) 27 | 28 | def empty(...) = render Command::Empty.new(...) 29 | 30 | private 31 | 32 | def default_attrs 33 | { 34 | data: { 35 | controller: "protos--popover", 36 | "protos--popover-options-value": JSON.generate(options) 37 | } 38 | } 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/protos/command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Command < Component 5 | # DOCS: A command pallete component that can be used to trigger a modal with 6 | # filterable list of commands. Command modals are by default closable by 7 | # clicking the overlay rather than a specific close button. 8 | 9 | autoload :Input, "protos/command/input" 10 | autoload :Dialog, "protos/command/dialog" 11 | autoload :Group, "protos/command/group" 12 | autoload :List, "protos/command/list" 13 | autoload :Trigger, "protos/command/trigger" 14 | autoload :Title, "protos/command/title" 15 | autoload :Item, "protos/command/item" 16 | autoload :Empty, "protos/command/empty" 17 | 18 | def view_template(&) 19 | div(**attrs, &) 20 | end 21 | 22 | def input(...) = render Command::Input.new(...) 23 | 24 | def list(...) = render Command::List.new(...) 25 | 26 | def trigger(...) = render Command::Trigger.new(...) 27 | 28 | def dialog(...) = render Modal::Dialog.new(...) 29 | 30 | def close_button(...) = render Modal::CloseButton.new(...) 31 | 32 | def title(...) = render Command::Title.new(...) 33 | 34 | def group(...) = render Command::Group.new(...) 35 | 36 | def item(...) = render Command::Item.new(...) 37 | 38 | def empty(...) = render Command::Empty.new(...) 39 | 40 | private 41 | 42 | def default_attrs 43 | { 44 | data: { 45 | controller: "protos--modal", 46 | action: "click->protos--modal#backdropClose" 47 | } 48 | } 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/protos/command/empty.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Command 5 | class Empty < Component 6 | # DOCS: The empty component is displayed in the list when there are no 7 | # matches in an input. 8 | 9 | def view_template(&) 10 | li(**attrs, &) 11 | end 12 | 13 | private 14 | 15 | def default_attrs 16 | { 17 | data: { "protos--command-target": "empty" } 18 | } 19 | end 20 | 21 | def theme 22 | { 23 | container: "hidden px-xs py-sm" 24 | } 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/protos/command/group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Command 5 | class Group < Component 6 | # DOCS: An optional group for holding a heading and a list of options. 7 | # Items in the group will have a divider line on the left side and can 8 | # optionally have a title. 9 | 10 | def view_template(&block) 11 | li(**attrs) do 12 | ul(class: css[:list], &block) 13 | end 14 | end 15 | 16 | private 17 | 18 | def default_attrs 19 | { 20 | data: { "protos--command-target": "group" } 21 | } 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/protos/command/input.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Command 5 | class Input < Component 6 | # DOCS: The search input for the command palette 7 | 8 | option :placeholder, 9 | reader: :private, 10 | default: -> { 11 | "Type a command or search..." 12 | } 13 | 14 | def view_template(&block) 15 | li(**attrs) do 16 | label(class: css[:label]) do 17 | div(class: css[:icon], &block) if block 18 | input( 19 | type: :text, 20 | data: { 21 | action: "protos--command#filter", 22 | "protos--command-target": "input" 23 | }, 24 | class: css[:input], 25 | placeholder:, 26 | autocomplete: :off 27 | ) 28 | end 29 | end 30 | end 31 | 32 | private 33 | 34 | def theme 35 | { 36 | container: "form-control", 37 | input: "grow bg-transparent", 38 | icon: "flex place-items-center", 39 | label: %w[ 40 | input 41 | input-bordered 42 | flex 43 | items-center 44 | gap-2 45 | ] 46 | } 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/protos/command/item.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Command 5 | class Item < Component 6 | # DOCS: A single option within a command 7 | 8 | def view_template(&) 9 | li(**attrs, &) 10 | end 11 | 12 | private 13 | 14 | def default_attrs 15 | { 16 | data: { 17 | "protos--command-target": "item", 18 | action: "turbo:submit-end->protos--modal#handleFormSubmit" 19 | } 20 | } 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/protos/command/list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Command 5 | class List < Component 6 | # DOCS: A list of commands. This can contain either items or groups. 7 | 8 | def view_template(&) 9 | ul(**attrs, &) 10 | end 11 | 12 | private 13 | 14 | def default_attrs 15 | { 16 | data: { controller: "protos--command" } 17 | } 18 | end 19 | 20 | def theme 21 | { 22 | container: "menu" 23 | } 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/protos/command/title.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Command 5 | class Title < Component 6 | # DOCS: The title for a group of commands. This is expected to be used 7 | # inside a Protos::Command::Group component. 8 | 9 | def view_template(&) 10 | li { h2(**attrs, &) } 11 | end 12 | 13 | private 14 | 15 | def theme 16 | { 17 | container: "menu-title" 18 | } 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/protos/command/trigger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Command 5 | class Trigger < Component 6 | # DOCS: A trigger is a button that opens a command palette modal 7 | 8 | def view_template(&) 9 | button(**attrs, &) 10 | end 11 | 12 | private 13 | 14 | def default_attrs 15 | { 16 | data: { action: "protos--modal#open" } 17 | } 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/protos/component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Component < Phlex::HTML 5 | # DOCS: Base component for all Protos::Components. You can inherit from this 6 | # class to gain flexible components you can style from the outside using css 7 | # slots, default attrs and themes. 8 | 9 | extend Dry::Initializer[undefined: false] 10 | 11 | # Theme can override the css hash and add additional styles 12 | option :theme, as: :theme_override, default: -> { {} }, reader: false 13 | # Class becomes the :container key in the css hash 14 | option :class, as: :container_class, default: -> { }, reader: false 15 | 16 | # Adds non-defined options to the html_options hash 17 | def initialize(*, **kwargs, &) 18 | super 19 | defined_keys = self.class.dry_initializer.definitions.keys 20 | extra_keys = kwargs.keys - defined_keys 21 | @html_options = kwargs.slice(*extra_keys) 22 | end 23 | 24 | private 25 | 26 | def attrs 27 | @attrs ||= build_attrs 28 | end 29 | 30 | def css 31 | @css ||= build_theme 32 | end 33 | 34 | def build_attrs(...) 35 | Attributes 36 | .new(...) 37 | .merge(default_attrs) 38 | .merge(@html_options) 39 | .merge(class: css[:container]) 40 | end 41 | 42 | def build_theme(...) 43 | Theme 44 | .new(...) 45 | .merge(theme) 46 | .merge(@theme_override) 47 | .merge(container: @container_class) 48 | end 49 | 50 | def default_attrs 51 | {} 52 | end 53 | 54 | def theme 55 | {} 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/protos/diff.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Diff < Component 5 | autoload :Item, "protos/diff/item" 6 | autoload :Resizer, "protos/diff/resizer" 7 | 8 | def view_template(&) 9 | figure(**attrs, &) 10 | end 11 | 12 | def item_one(*, **, &) = render Item.new(*, order: :one, **, &) 13 | def item_two(*, **, &) = render Item.new(*, order: :two, **, &) 14 | def resizer(...) = render Resizer.new(...) 15 | 16 | private 17 | 18 | def default_attrs 19 | { 20 | tabindex: 0 21 | } 22 | end 23 | 24 | def theme 25 | { 26 | container: "diff" 27 | } 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/protos/diff/item.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Diff 5 | class Item < Component 6 | Order = Types::Coercible::Symbol.enum(:one, :two) 7 | 8 | ORDER = { 9 | one: "diff-item-1", 10 | two: "diff-item-2" 11 | }.freeze 12 | 13 | option :order, type: Order, default: -> { :one }, reader: false 14 | 15 | def view_template(&) 16 | div(**attrs, &) 17 | end 18 | 19 | private 20 | 21 | def default_attrs 22 | { 23 | role: :img 24 | } 25 | end 26 | 27 | def theme 28 | { 29 | container: ORDER.fetch(@order) 30 | } 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/protos/diff/resizer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Diff 5 | class Resizer < Component 6 | def view_template(&) 7 | div(**attrs, &) 8 | end 9 | 10 | private 11 | 12 | def theme 13 | { 14 | container: "diff-resizer" 15 | } 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/protos/drawer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Drawer < Component 5 | # DOCS: A drawer component that can be toggled via a trigger. The content of 6 | # a drawer is displayed at all times and overlapped by the side when the 7 | # trigger is clicked. 8 | # https://daisyui.com/components/drawer/ 9 | 10 | autoload :Side, "protos/drawer/side" 11 | autoload :Trigger, "protos/drawer/trigger" 12 | autoload :Content, "protos/drawer/content" 13 | 14 | option :id, 15 | reader: false, 16 | type: Types::Coercible::String, 17 | default: -> { "drawer-#{SecureRandom.hex(4)}" } 18 | 19 | def view_template 20 | div(**attrs) do 21 | input( 22 | id: @id, 23 | type: :checkbox, 24 | class: css[:toggle], 25 | autocomplete: :off, 26 | form: "" 27 | ) 28 | yield if block_given? 29 | end 30 | end 31 | 32 | def content(...) = render Content.new(...) 33 | 34 | def side(*, **, &) = render Side.new(*, input_id: @id, **, &) 35 | 36 | def trigger(*, **, &) = render Trigger.new(*, input_id: @id, **, &) 37 | 38 | private 39 | 40 | def theme 41 | { 42 | container: "drawer", 43 | toggle: "drawer-toggle peer" 44 | } 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/protos/drawer/content.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Drawer 5 | class Content < Component 6 | # DOCS: The content of a drawer. This would be visible at all times and 7 | # represents the main content of your page. 8 | 9 | def view_template(&) 10 | div(**attrs, &) 11 | end 12 | 13 | private 14 | 15 | def theme 16 | { 17 | container: "drawer-content" 18 | } 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/protos/drawer/side.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Drawer 5 | class Side < Component 6 | # DOCS: The content that will be shown when you open the drawer using the 7 | # trigger. This would be hidden until triggered. 8 | 9 | option :input_id, reader: false, type: Types::String 10 | 11 | def view_template 12 | aside(**attrs) do 13 | label( 14 | for: @input_id, 15 | aria_label: "close sidebar", 16 | class: css[:toggle] 17 | ) 18 | yield if block_given? 19 | end 20 | end 21 | 22 | private 23 | 24 | def theme 25 | { 26 | container: %w[ 27 | drawer-side 28 | z-20 29 | peer-checked:backdrop-blur-sm 30 | ], 31 | toggle: "drawer-overlay" 32 | } 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/protos/drawer/trigger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Drawer 5 | class Trigger < Component 6 | # DOCS: The trigger for a drawer. When this is clicked the side will open 7 | # and overlap the main content with a darker overlay. 8 | 9 | option :input_id, reader: false, type: Types::String 10 | 11 | def view_template(&) 12 | label(for: @input_id, **attrs, &) 13 | end 14 | 15 | private 16 | 17 | def theme 18 | { 19 | container: "drawer-button" 20 | } 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/protos/dropdown.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Dropdown < Popover 5 | # DOCS: A dropdown component is basically a popover with a specific list of 6 | # items in a menu. It should be preferred to a popover when the content is 7 | # a list of actions rather than some content in its own right. 8 | # 9 | # Dropdowns, and popovers in general, use tippy.js to position content 10 | # rather than pure CSS for accessibility. The layout of pure CSS was too 11 | # tricky to get right and we felt the dependency tradeoff was worthwhile. 12 | 13 | autoload :Item, "protos/dropdown/item" 14 | autoload :Menu, "protos/dropdown/menu" 15 | autoload :Trigger, "protos/dropdown/trigger" 16 | 17 | option :position, 18 | type: Popover::Positions, 19 | default: -> { :bottom }, 20 | reader: false 21 | option :trigger, 22 | default: -> { :click }, 23 | reader: false, 24 | type: Popover::Triggers | Types::Array.of(Popover::Triggers) 25 | 26 | def item(...) = render Item.new(...) 27 | 28 | def menu(...) = render Menu.new(...) 29 | 30 | def trigger(...) = render Trigger.new(...) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/protos/dropdown/item.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Dropdown 5 | class Item < Component 6 | # DOCS: A single item within a dropdown 7 | 8 | def view_template(&) 9 | li(**attrs, &) 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/protos/dropdown/menu.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Dropdown 5 | class Menu < Popover::Content 6 | # DOCS: The container for items in a dropdown. This is a restyled 7 | # Protos::Popover::Content component as the main functionality for 8 | # dropdowns comes from there. 9 | 10 | def view_template(&block) 11 | template(**template_attrs) do 12 | render ::Protos::Menu.new(**attrs, &block) 13 | end 14 | end 15 | 16 | private 17 | 18 | def theme 19 | { 20 | container: %w[ 21 | dropdown-content 22 | z-10 23 | ] 24 | } 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/protos/dropdown/trigger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Dropdown 5 | class Trigger < Popover::Trigger 6 | # DOCS: The trigger for a dropdown. This inherits from the trigger of 7 | # a popover as the main functionality comes from there. 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/protos/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Engine < ::Rails::Engine 5 | # DOCS: This is the engine for the Protos gem. It allows autoloading the 6 | # lib when used inside a Rails app. 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/protos/hero.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Hero < Component 5 | # DOCS: A hero component for a page. It will center the content an 6 | # optionally layout an image for a responsive layout. 7 | # https://daisyui.com/components/hero/ 8 | 9 | autoload :Content, "protos/hero/content" 10 | autoload :Overlay, "protos/hero/overlay" 11 | 12 | def view_template(&) 13 | div(**attrs, &) 14 | end 15 | 16 | def content(...) = render Content.new(...) 17 | 18 | def overlay(...) = render Overlay.new(...) 19 | 20 | private 21 | 22 | def theme 23 | { 24 | container: "hero" 25 | } 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/protos/hero/content.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Hero 5 | class Content < Component 6 | # DOCS: The content of a hero. This would be centered within the main 7 | # component container. 8 | 9 | def view_template(&) 10 | div(**attrs, &) 11 | end 12 | 13 | private 14 | 15 | def theme 16 | { 17 | container: "hero-content" 18 | } 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/protos/hero/overlay.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Hero 5 | class Overlay < Component 6 | # DOCS: The overlay of a hero. This would be used with images to reduce 7 | # their opacity through the opacity of the overlay. This can be useful to 8 | # make text readable on noisy images. 9 | 10 | def view_template(&) 11 | div(**attrs, &) 12 | end 13 | 14 | private 15 | 16 | def theme 17 | { 18 | container: "hero-overlay" 19 | } 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/protos/list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class List < Component 5 | # DOCS: A list of items that are joined together for easier styling with 6 | # borders, border radius, etc. 7 | 8 | autoload :Item, "protos/list/item" 9 | 10 | option :ordered, Types::Bool, default: -> { false }, reader: false 11 | 12 | def view_template(&) 13 | send(element, **attrs, &) 14 | end 15 | 16 | def item(...) = render Item.new(...) 17 | 18 | private 19 | 20 | def theme 21 | { 22 | container: "list" 23 | } 24 | end 25 | 26 | def element 27 | @ordered ? :ol : :ul 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/protos/list/item.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class List 5 | class Item < Component 6 | # DOCS: A single item within a list. Items are joined so that borders will 7 | # work for list items, including border radius. E.g. only the first and 8 | # last items will have border radius on the top and bottom. 9 | 10 | option :wrap, Types::Bool, default: -> { false }, reader: :private 11 | option :grow, Types::Bool, default: -> { false }, reader: :private 12 | 13 | def view_template(&) 14 | li(**attrs, &) 15 | end 16 | 17 | private 18 | 19 | def theme 20 | { 21 | container: [ 22 | "list-row", 23 | ("list-col-wrap" if wrap), 24 | ("list-col-grow" if grow) 25 | ] 26 | } 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/protos/menu.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Menu < Component 5 | # DOCS: A menu of links or actions. 6 | # https://daisyui.com/components/menu/ 7 | 8 | Directions = Types::Coercible::Symbol.enum(:vertical, :horizontal) 9 | Sizes = Types::Coercible::Symbol.enum(:xs, :sm, :md, :lg, :xl) 10 | 11 | DIRECTIONS = { 12 | vertical: "", 13 | horizontal: "menu-horizontal" 14 | }.freeze 15 | 16 | SIZES = { 17 | xs: "menu-xs", 18 | sm: "menu-sm", 19 | md: "menu-md", 20 | lg: "menu-lg", 21 | xl: "menu-xl" 22 | }.freeze 23 | 24 | autoload :Item, "protos/menu/item" 25 | 26 | option :size, 27 | type: Sizes, 28 | default: -> { :md }, 29 | reader: :private 30 | 31 | option :direction, 32 | type: Directions, 33 | default: -> { :vertical }, 34 | reader: :private 35 | 36 | def view_template(&) 37 | ul(**attrs, &) 38 | end 39 | 40 | def item(...) 41 | render Item.new(...) 42 | end 43 | 44 | private 45 | 46 | def theme 47 | { 48 | container: [ 49 | "menu", 50 | DIRECTIONS[direction], 51 | SIZES[size] 52 | ] 53 | } 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/protos/menu/item.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Menu 5 | class Item < Component 6 | option :title, type: Types::Bool, default: -> { false } 7 | 8 | def view_template(&) 9 | li(**attrs, &) 10 | end 11 | 12 | private 13 | 14 | def theme 15 | { 16 | container: [ 17 | ("menu-title" if title) 18 | ] 19 | } 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/protos/mix.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | module Mix 5 | # DOCS: This class is responsible for safely merging in both user supplied 6 | # and default attributes. When a user adds { data: { controller: "foo" }} to 7 | # their component. This will merge the value in so that any default 8 | # controllers do not get overridden. 9 | 10 | module_function 11 | 12 | MERGEABLE_ATTRIBUTES = Set.new(%i[class data]).freeze 13 | 14 | def call(old_hash, *hashes) 15 | hashes 16 | .compact 17 | .each_with_object(old_hash) do |new_hash, result| 18 | merge(result, new_hash, top_level: true) 19 | end 20 | end 21 | 22 | def merge(old_hash, new_hash, top_level: false) # rubocop:disable Metrics/PerceivedComplexity 23 | old_hash.merge!(new_hash) do |key, old, new| 24 | next old unless new 25 | next old if old == new 26 | next new if top_level && !MERGEABLE_ATTRIBUTES.include?(key) 27 | 28 | case [old, new] 29 | in String, String then "#{old} #{new}" 30 | in Array, Array then old + new 31 | in Hash, Hash then merge(old, new) 32 | else new 33 | end 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/protos/modal.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Modal < Protos::Component 5 | # DOCS: A modal component that can be triggered by a button or a link and 6 | # will open a fullscreen modal, usually with a close button. 7 | 8 | autoload :CloseButton, "protos/modal/close_button" 9 | autoload :Dialog, "protos/modal/dialog" 10 | autoload :Trigger, "protos/modal/trigger" 11 | 12 | def view_template(&) 13 | div(**attrs, class: css[:container], &) 14 | end 15 | 16 | def close_button(...) = render CloseButton.new(...) 17 | 18 | def dialog(...) = render Dialog.new(...) 19 | 20 | def trigger(...) = render Trigger.new(...) 21 | 22 | private 23 | 24 | def default_attrs 25 | { 26 | data: { 27 | controller: "protos--modal", 28 | action: "click->protos--modal#backdropClose" 29 | } 30 | } 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/protos/modal/close_button.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Modal 5 | class CloseButton < Component 6 | # DOCS: A close button for a modal 7 | 8 | def view_template(&block) 9 | form(method: :dialog, class: css[:form]) do 10 | button(**attrs, &block) 11 | end 12 | end 13 | 14 | private 15 | 16 | def default_attrs 17 | { 18 | data: { action: "protos--modal#close" } 19 | } 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/protos/modal/dialog.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Modal 5 | class Dialog < Component 6 | # DOCS: A modal dialog. This is the place for the main content of the 7 | # modal that will be displayed when the trigger is clicked. 8 | 9 | def view_template(&block) 10 | dialog(**attrs) do 11 | div(class: css[:modal], &block) 12 | end 13 | end 14 | 15 | private 16 | 17 | def default_attrs 18 | { 19 | data: { "protos--modal-target": "dialog" } 20 | } 21 | end 22 | 23 | def theme 24 | { 25 | container: %w[ 26 | modal 27 | modal-bottom 28 | backdrop-blur-sm 29 | sm:modal-middle 30 | ], 31 | modal: %w[ 32 | modal-box 33 | flex 34 | flex-col 35 | gap-xs 36 | ] 37 | } 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/protos/modal/trigger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Modal 5 | class Trigger < Component 6 | # DOCS: A trigger is a button that opens a modal 7 | 8 | def view_template(&) 9 | button(**attrs, &) 10 | end 11 | 12 | private 13 | 14 | def default_attrs 15 | { 16 | data: { action: "protos--modal#open" } 17 | } 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/protos/popover.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Popover < Component 5 | # DOCS: Like a dropdown, but instead of a list of menu items, its just 6 | # a rendered block. The popover is triggered by a button or link. 7 | # 8 | # Dropdowns, and popovers in general, use tippy.js to position content 9 | # rather than pure CSS for accessibility. The layout of pure CSS was too 10 | # tricky to get right and we felt the dependency tradeoff was worthwhile. 11 | # 12 | # Tippy.js options can be passed in via the `options` attribute or, more 13 | # conveniently by the typed options listed below. 14 | 15 | autoload :Trigger, "protos/popover/trigger" 16 | autoload :Content, "protos/popover/content" 17 | 18 | Positions = Types::Coercible::Symbol.enum( 19 | :top, 20 | :top_start, 21 | :top_end, 22 | :right, 23 | :right_start, 24 | :right_end, 25 | :bottom, 26 | :bottom_start, 27 | :bottom_end, 28 | :left, 29 | :left_start, 30 | :left_end 31 | ) 32 | 33 | Animations = Types::Coercible::Symbol.enum( 34 | :fade, 35 | :shift_away, 36 | :shift_away_subtle, 37 | :shift_away_extreme, 38 | :shift_towards, 39 | :shift_towards_subtle, 40 | :shift_towards_extreme, 41 | :scale, 42 | :scale_subtle, 43 | :scale_extreme, 44 | :perspective, 45 | :perspective_subtle, 46 | :perspective_extreme 47 | ) 48 | 49 | Triggers = Types::Coercible::Symbol.enum( 50 | :focus, 51 | :mouseenter, 52 | :click, 53 | :focusin, 54 | :manual 55 | ) 56 | 57 | option :position, 58 | type: Positions, 59 | default: -> { :top }, 60 | reader: false 61 | option :animation, 62 | type: Animations, 63 | default: -> { :fade }, 64 | reader: false 65 | option :duration, 66 | type: Types::Integer | Types::Array.of(Types::Integer), 67 | default: -> { [300, 250] }, 68 | reader: false 69 | option :hide_on_click, 70 | type: Types::Bool | Types.Value(:toggle), 71 | default: -> { true }, 72 | reader: false 73 | option :z_index, 74 | type: Types::Integer, 75 | default: -> { 9999 }, 76 | reader: false 77 | option :options, 78 | default: -> { {} }, 79 | reader: false, 80 | type: Types::Hash 81 | option :trigger, 82 | default: -> { %i[mouseenter focus] }, 83 | reader: false, 84 | type: Triggers | Types::Array.of(Triggers) 85 | 86 | def view_template(&) 87 | div(**attrs, &) 88 | end 89 | 90 | def content(...) = render Content.new(...) 91 | 92 | def trigger(...) = render Trigger.new(...) 93 | 94 | private 95 | 96 | def dasherize(string) 97 | string.to_s.tr("_", "-") 98 | end 99 | 100 | def options 101 | opts = {} 102 | opts[:animation] = dasherize(@animation) 103 | opts[:placement] = dasherize(@position) 104 | opts[:duration] = @duration 105 | opts[:hideOnClick] = @hide_on_click 106 | opts[:zIndex] = @z_index 107 | opts[:trigger] = Array(@trigger).flatten.map(&:to_s).join(" ") 108 | opts.merge(@options) 109 | end 110 | 111 | def default_attrs 112 | { 113 | data: { 114 | controller: "protos--popover", 115 | "protos--popover-options-value": JSON.generate(options) 116 | } 117 | } 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/protos/popover/content.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Protos 4 | class Popover 5 | class Content < Component 6 | # DOCS: The content of a popover. We use a