basecoat 2.2.0 → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 92a3b3db00be4f62dcf379eac035135cb5161b3bc68e9f1d0c7121e87c88b387
4
- data.tar.gz: 45d0cb31d2da696369191e60945ba66b25b590e1c321b03df641b4583ba7fcd8
3
+ metadata.gz: 745c8eb3926d13292445d2d4fbd0b7550bfe5573ef3888ed27c19fc8018fe9c4
4
+ data.tar.gz: 321640c46d3f61ee0b87beb7214be8f4cd82ed31f10f4291a26d99a9cb455430
5
5
  SHA512:
6
- metadata.gz: ef24e7f5c6dd6588cb64fc0a79efd8830e86a80bce99b6f621dbde384c218cc69e2ac25c6119d8986327789313c25a0269adbe065ca4cbf7c224099cb2640767
7
- data.tar.gz: 86047f8393f17eb1aa5407bc5f19ea578c3522b053dd06c7d156c57ade2e8c3c989233a62da9ab5038ee5a7e6b498dd7a06b97856c81744a799d2a993941c044
6
+ metadata.gz: 8a415461f36459cca8d34235c3939c51b8aa754bc68c5691ebc65f083bc8e2c6a086f600309c326127cb38e3a45f3b3a61dcc0c3684936b4641a2ba0ffc8a566
7
+ data.tar.gz: d021c20201711920476b6985174c60d27a8e4fa9d5faaaadb473196a15c9b7407000ad903e584b200df122ef9d69b2d0366302eef25b52078a4d6076d32eee47
data/README.md CHANGED
@@ -30,6 +30,143 @@ Basecoat CSS combines tailwind with clean css classes. It creates the looks of s
30
30
 
31
31
  If you need more complex components; enrich the views with https://railsblocks.com/ or https://shadcn.rails-components.com/ or just the shadcn React components themselves.
32
32
 
33
+ ## Icons
34
+
35
+ Basecoat uses the [lucide-rails](https://github.com/heyvito/lucide-rails) gem for beautiful, consistent icons throughout the UI. The gem is automatically installed as a dependency.
36
+
37
+ You can use Lucide icons in your views with the `lucide_icon` helper:
38
+
39
+ ```erb
40
+ <%= lucide_icon "home" %>
41
+ <%= lucide_icon "user", class: "w-5 h-5" %>
42
+ ```
43
+
44
+ Browse available icons at [lucide.dev](https://lucide.dev/icons/).
45
+
46
+ ### Using Different Icon Libraries
47
+
48
+ If you prefer to use a different icon library, you can switch to the [rails_icons](https://github.com/nejdetkadir/rails_icons) gem which supports multiple icon libraries including Lucide, Font Awesome, Heroicons, and more. Simply:
49
+
50
+ 1. Add `rails_icons` to your Gemfile
51
+ 2. Replace `lucide_icon` calls with `icon` calls from rails_icons in the generated views
52
+ 3. Configure your preferred icon library in `config/initializers/rails_icons.rb`
53
+
54
+ ## Form Helpers
55
+
56
+ Basecoat includes custom form helpers for enhanced UI components:
57
+
58
+ ### Select Component
59
+
60
+ Use `basecoat_select` in forms or `basecoat_select_tag` outside of forms:
61
+
62
+ ```erb
63
+ <%= form_for @user do |f| %>
64
+ <%= f.basecoat_select :fruit, [["Apple", 1], ["Pear", 2]] %>
65
+ <% end %>
66
+
67
+ <%# Or without a form: %>
68
+ <%= basecoat_select_tag "fruit", [["Apple", 1], ["Pear", 2]] %>
69
+
70
+ <%# With options: %>
71
+ <%= f.basecoat_select :fruit, [["Apple", 1], ["Pear", 2]],
72
+ group_label: "Fruits",
73
+ placeholder: "Search fruits..." %>
74
+
75
+ <%# You can add a remote url, which does a turbo call %>
76
+ <%= f.basecoat_select :fruit, [[]], url: "/fruits/search", turbo_frame: "custom_frame" %>
77
+
78
+ `fruits/search.turbo_stream.erb` should then have the following content:
79
+
80
+ <%= turbo_stream.update "custom_frame" do %>
81
+ <% @fruits.each do |fruit| %>
82
+ <%= tag.div fruit.name, role: "option", data: { value: fruit.id } %>
83
+ <% end %>
84
+ <% end %>
85
+
86
+ # If you don't add the turbo_frame option there's a fallback to underscored URL (_fruits_search)
87
+ <%= f.basecoat_select :fruit, [[]], url: "/fruits/search" %>
88
+
89
+ Make sure this matches the frame in your partial!
90
+ ```
91
+
92
+ ### Remote Search Component
93
+
94
+ Use `basecoat_remote_search_tag` for a standalone search component with Turbo Stream support:
95
+
96
+ ```erb
97
+ <%# Basic usage: %>
98
+ <%= basecoat_remote_search_tag("/posts/search") %>
99
+
100
+ <%# With custom turbo frame name: %>
101
+ <%= basecoat_remote_search_tag("/posts/search", turbo_frame: "custom_frame") %>
102
+
103
+ <%# With custom placeholder: %>
104
+ <%= basecoat_remote_search_tag("/posts/search", placeholder: "Search posts...") %>
105
+ ```
106
+
107
+ Your controller should respond with a Turbo Stream that updates the frame:
108
+
109
+ ```ruby
110
+ # posts_controller.rb
111
+ def search
112
+ @posts = Post.where("title LIKE ?", "%#{params[:query]}%")
113
+
114
+ respond_to do |format|
115
+ format.turbo_stream
116
+ end
117
+ end
118
+ ```
119
+
120
+ ```erb
121
+ <%# posts/search.turbo_stream.erb %>
122
+ <%= turbo_stream.update "_posts_search" do %>
123
+ <% @posts.each do |post| %>
124
+ <%= link_to post.title, post_path(post), class: "block p-2 hover:bg-muted" %>
125
+ <% end %>
126
+ <% end %>
127
+ ```
128
+
129
+ Options:
130
+ - `url` (required): The URL to fetch search results from
131
+ - `turbo_frame`: Custom turbo frame name (defaults to underscored URL, e.g., `/posts/search` becomes `_posts_search`)
132
+ - `placeholder`: Custom placeholder text (defaults to "Type a command or search...")
133
+
134
+ ### Country Select Component
135
+
136
+ Use `basecoat_country_select_tag` for a country picker with flag emojis:
137
+
138
+ ```erb
139
+ <%# Basic usage (all countries): %>
140
+ <%= basecoat_country_select_tag :country %>
141
+
142
+ <%# With pre-selected country: %>
143
+ <%= basecoat_country_select_tag :country, selected: "US" %>
144
+
145
+ <%# With priority countries at the top: %>
146
+ <%= basecoat_country_select_tag :country, priority: ["US", "CA", "GB"] %>
147
+
148
+ <%# Only show specific countries: %>
149
+ <%= basecoat_country_select_tag :country, countries: ["US", "CA", "MX"] %>
150
+
151
+ <%# Exclude specific countries: %>
152
+ <%= basecoat_country_select_tag :country, except: ["KP", "IR"] %>
153
+
154
+ <%# In a form: %>
155
+ <%= form_for @user do |f| %>
156
+ <%= f.label :country %>
157
+ <%= f.basecoat_country_select :country %>
158
+ <% end %>
159
+ ```
160
+
161
+ Options:
162
+ - `selected`: Pre-select a country by its ISO 3166-1 alpha-2 code (e.g., "US")
163
+ - `priority`: Array of country codes to show at the top of the list
164
+ - `countries`: Array of country codes to include (shows only these countries)
165
+ - `except`: Array of country codes to exclude
166
+ - All `basecoat_select_tag` options (placeholder, scrollable, etc.)
167
+
168
+ **Note:** This helper requires the `countries` gem, which is automatically installed with `rake basecoat:install`.
169
+
33
170
  ## Rake tasks
34
171
 
35
172
  ### Layout (required)
@@ -55,7 +192,7 @@ The scaffold templates are automatically available from the gem, so you can imme
55
192
 
56
193
  Install the Basecoat-styled authentication views (for Rails built-in authentication):
57
194
 
58
- rails generate:authentication
195
+ rails generate authentication
59
196
  rails db:migrate
60
197
  rake basecoat:install:authentication
61
198
 
@@ -0,0 +1,22 @@
1
+ module Basecoat
2
+ module FormBuilder
3
+ def basecoat_select(method, choices, options = {}, html_options = {})
4
+ value = @object.public_send(method) if @object
5
+ options = options.merge(selected: value) if value
6
+
7
+ name = "#{@object_name}[#{method}]"
8
+ options[:group_label] ||= method.to_s.titleize.pluralize
9
+
10
+ @template.basecoat_select_tag(name, choices, options)
11
+ end
12
+
13
+ def basecoat_country_select(method, options = {})
14
+ value = @object.public_send(method) if @object
15
+ options = options.merge(selected: value) if value
16
+
17
+ name = "#{@object_name}[#{method}]"
18
+
19
+ @template.basecoat_country_select_tag(name, options)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,239 @@
1
+ module Basecoat
2
+ module FormHelper
3
+ ##
4
+ # Renders a remote search component with Turbo Stream support.
5
+ #
6
+ # ==== Parameters
7
+ # * +url+ - The URL to fetch search results from (required)
8
+ # * +options+ - Hash of optional parameters:
9
+ # * +:turbo_frame+ - Custom turbo frame name (defaults to underscored URL)
10
+ # * +:placeholder+ - Custom placeholder text (defaults to "Type a command or search...")
11
+ # * +:data_empty+ - Message shown when no results found (defaults to "No results found.")
12
+ # * +:classes+ - Additional CSS classes to apply to the component
13
+ #
14
+ # ==== Examples
15
+ # <%= basecoat_remote_search_tag("/posts/search") %>
16
+ # <%= basecoat_remote_search_tag("/posts/search", turbo_frame: "custom_frame") %>
17
+ # <%= basecoat_remote_search_tag("/posts/search", placeholder: "Search posts...", classes: "rounded-lg border shadow-md") %>
18
+ def basecoat_remote_search_tag(url, options = {})
19
+ turbo_frame = options[:turbo_frame] || url.gsub("/", "_")
20
+ placeholder = options[:placeholder] || "Type a command or search..."
21
+ data_empty = options[:data_empty] || "No results found."
22
+ classes = options[:classes] || ""
23
+ search_id = "search-#{SecureRandom.random_number(1000000)}"
24
+
25
+ content_tag(:div, id: search_id, data: { controller: "search", search_url_value: url }, class: "command #{classes}", "aria-label": "Command menu") do
26
+ content_tag(:header) do
27
+ lucide_icon("search").html_safe +
28
+ tag(:input,
29
+ type: "search",
30
+ id: "#{search_id}-input",
31
+ placeholder: placeholder,
32
+ autocomplete: "off",
33
+ autocorrect: "off",
34
+ spellcheck: "false",
35
+ "aria-autocomplete": "list",
36
+ role: "combobox",
37
+ "aria-expanded": "true",
38
+ "aria-controls": "#{search_id}-menu",
39
+ data: {
40
+ search_target: "input",
41
+ action: "input->search#search"
42
+ }
43
+ )
44
+ end +
45
+ content_tag(:div, role: "menu", id: "#{search_id}-menu", "aria-orientation": "vertical", data: { empty: data_empty }, class: "scrollbar") do
46
+ turbo_frame_tag(turbo_frame)
47
+ end
48
+ end
49
+ end
50
+
51
+ ##
52
+ # Renders a select component outside of a form context.
53
+ #
54
+ # ==== Parameters
55
+ # * +name+ - The name attribute for the hidden input field (required)
56
+ # * +choices+ - Array of [label, value] pairs for select options (required)
57
+ # * +options+ - Hash of optional parameters:
58
+ # * +:selected+ - The initially selected value (defaults to first choice)
59
+ # * +:group_label+ - Label for the option group (defaults to titleized and pluralized name)
60
+ # * +:placeholder+ - Placeholder text for the search input (defaults to "Search entries...")
61
+ # * +:url+ - URL for remote search via Turbo Stream
62
+ # * +:turbo_frame+ - Custom turbo frame name when using +:url+ (defaults to underscored URL)
63
+ # * +:scrollable+ - Whether to make the listbox scrollable with max height (defaults to false)
64
+ #
65
+ # ==== Examples
66
+ # <%= basecoat_select_tag "fruit", [["Apple", 1], ["Pear", 2]] %>
67
+ # <%= basecoat_select_tag "fruit", [["Apple", 1], ["Pear", 2]], selected: 2, placeholder: "Search fruits..." %>
68
+ # <%= basecoat_select_tag "fruit", [], url: "/fruits/search", turbo_frame: "custom_frame", scrollable: true %>
69
+ def basecoat_select_tag(name, choices, options = {})
70
+ select_id = "select-#{SecureRandom.random_number(1000000)}"
71
+ url = options[:url]
72
+
73
+ if url || choices.empty?
74
+ selected_value = options[:selected]
75
+ selected_label = options[:selected_label] || "Select..."
76
+ else
77
+ selected_value = options[:selected] || choices.first[1]
78
+ selected_choice = choices.find { |label, val| val.to_s == selected_value.to_s }
79
+ selected_label = selected_choice ? selected_choice[0] : choices.first[0]
80
+ end
81
+
82
+ group_label = options[:group_label] || name.to_s.titleize.pluralize
83
+ placeholder = options[:placeholder] || "Search entries..."
84
+ scrollable = options[:scrollable] || false
85
+ turbo_frame = options[:turbo_frame] || (url ? url.gsub("/", "_") : nil)
86
+
87
+ select_attrs = { id: select_id, class: "select" }
88
+ if url
89
+ select_attrs[:data] = {
90
+ controller: "search",
91
+ search_url_value: url
92
+ }
93
+ end
94
+
95
+ content_tag(:div, select_attrs) do
96
+ basecoat_select_button(select_id, selected_label) +
97
+ basecoat_select_popover(select_id, choices, group_label, placeholder, selected_value, url, scrollable, turbo_frame) +
98
+ tag(:input, type: "hidden", name: name, value: selected_value)
99
+ end
100
+ end
101
+
102
+ ##
103
+ # Renders a country select component with flag emojis.
104
+ #
105
+ # ==== Parameters
106
+ # * +name+ - The name attribute for the hidden input field (required)
107
+ # * +options+ - Hash of optional parameters:
108
+ # * +:selected+ - The initially selected country code (e.g., "US")
109
+ # * +:countries+ - Array of country codes to include (defaults to all countries)
110
+ # * +:priority+ - Array of priority country codes to show at the top
111
+ # * +:except+ - Array of country codes to exclude
112
+ # * All other basecoat_select_tag options (placeholder, scrollable, etc.)
113
+ #
114
+ # ==== Examples
115
+ # <%= basecoat_country_select_tag :country %>
116
+ # <%= basecoat_country_select_tag :country, selected: "US" %>
117
+ # <%= basecoat_country_select_tag :country, priority: ["US", "CA", "GB"] %>
118
+ # <%= basecoat_country_select_tag :country, countries: ["US", "CA", "MX"] %>
119
+ def basecoat_country_select_tag(name, options = {})
120
+ require 'countries'
121
+
122
+ # Extract country-specific options
123
+ country_codes = options.delete(:countries)
124
+ priority_codes = options.delete(:priority) || []
125
+ except_codes = options.delete(:except) || []
126
+
127
+ # Get all countries or filtered list
128
+ all_countries = if country_codes
129
+ country_codes.map { |code| ISO3166::Country[code] }.compact
130
+ else
131
+ ISO3166::Country.all
132
+ end
133
+
134
+ # Remove excluded countries
135
+ all_countries.reject! { |country| except_codes.include?(country.alpha2) } if except_codes.any?
136
+
137
+ # Build choices with flag emoji
138
+ choices = all_countries.map do |country|
139
+ ["#{country.emoji_flag} #{country.iso_short_name}", country.alpha2]
140
+ end
141
+
142
+ # Sort alphabetically by country name
143
+ choices.sort_by! { |label, _| label }
144
+
145
+ # Add priority countries at the top
146
+ if priority_codes.any?
147
+ priority_choices = priority_codes.map do |code|
148
+ country = ISO3166::Country[code]
149
+ next unless country
150
+ ["#{country.emoji_flag} #{country.iso_short_name}", country.alpha2]
151
+ end.compact
152
+
153
+ choices.reject! { |_, code| priority_codes.include?(code) }
154
+ choices = priority_choices + [["---", nil]] + choices
155
+ end
156
+
157
+ # Update selected_label if a country is selected
158
+ if options[:selected]
159
+ country = ISO3166::Country[options[:selected]]
160
+ options[:selected_label] = "#{country.emoji_flag} #{country.iso_short_name}" if country
161
+ end
162
+
163
+ # Set defaults
164
+ options[:placeholder] ||= "Search countries..."
165
+ options[:group_label] ||= "Countries"
166
+ options[:scrollable] = true unless options.key?(:scrollable)
167
+
168
+ basecoat_select_tag(name, choices, options)
169
+
170
+ rescue LoadError
171
+ content_tag :div, class: "alert-destructive" do
172
+ lucide_icon("circle-alert") + tag.section("gem 'countries' required")
173
+ end
174
+ end
175
+
176
+ private
177
+
178
+ def basecoat_select_button(select_id, selected_label)
179
+ content_tag(:button, type: "button", class: "btn-outline justify-between font-normal w-[180px]", id: "#{select_id}-trigger", "aria-haspopup": "listbox", "aria-expanded": "false", "aria-controls": "#{select_id}-listbox") do
180
+ content_tag(:span, selected_label, class: "truncate") +
181
+ lucide_icon("chevrons-up-down", class: "text-muted-foreground opacity-50 shrink-0").html_safe
182
+ end
183
+ end
184
+
185
+ def basecoat_select_popover(select_id, choices, group_label, placeholder, selected_value, url, scrollable, turbo_frame)
186
+ content_tag(:div, id: "#{select_id}-popover", data: { popover: true }, "aria-hidden": "true") do
187
+ basecoat_select_search_header(select_id, placeholder) +
188
+ basecoat_select_listbox(select_id, choices, group_label, selected_value, url, scrollable, turbo_frame)
189
+ end
190
+ end
191
+
192
+ def basecoat_select_search_header(select_id, placeholder)
193
+ content_tag(:header) do
194
+ lucide_icon("search").html_safe +
195
+ tag(:input, type: "text", value: "", placeholder: placeholder, autocomplete: "off", autocorrect: "off", spellcheck: "false", "aria-autocomplete": "list", role: "combobox", "aria-expanded": "false", "aria-controls": "#{select_id}-listbox", "aria-labelledby": "#{select_id}-trigger", data: { search_target: "input", action: "input->search#search" })
196
+ end
197
+ end
198
+
199
+ def basecoat_select_listbox(select_id, choices, group_label, selected_value, url, scrollable, turbo_frame)
200
+ listbox_attrs = {
201
+ role: "listbox",
202
+ id: "#{select_id}-listbox",
203
+ "aria-orientation": "vertical",
204
+ "aria-labelledby": "#{select_id}-trigger"
205
+ }
206
+
207
+ listbox_attrs[:class] = "scrollbar overflow-y-auto max-h-64" if scrollable
208
+
209
+ if url
210
+ listbox_attrs[:data] = {
211
+ controller: "search",
212
+ search_url_value: url
213
+ }
214
+ end
215
+
216
+ content_tag(:div, listbox_attrs) do
217
+ if url
218
+ turbo_frame_tag(turbo_frame)
219
+ else
220
+ content_tag(:div, role: "group", "aria-labelledby": "group-label-#{select_id}-items-1") do
221
+ content_tag(:div, group_label, role: "heading", id: "group-label-#{select_id}-items-1") +
222
+ choices.map.with_index do |(label, value), index|
223
+ basecoat_select_option(select_id, label, value, index + 1, value.to_s == selected_value.to_s)
224
+ end.join.html_safe
225
+ end
226
+ end
227
+ end
228
+ end
229
+
230
+ def basecoat_select_option(select_id, label, value, index, selected)
231
+ content_tag(:div, label,
232
+ id: "#{select_id}-items-1-#{index}",
233
+ role: "option",
234
+ "data-value": value,
235
+ "aria-selected": selected
236
+ )
237
+ end
238
+ end
239
+ end
@@ -7,5 +7,12 @@ module Basecoat
7
7
  config.app_generators do |g|
8
8
  g.templates.unshift File.expand_path("../templates", __dir__)
9
9
  end
10
+
11
+ initializer "basecoat.form_builder" do
12
+ ActiveSupport.on_load(:action_view) do
13
+ ActionView::Helpers::FormBuilder.include Basecoat::FormBuilder
14
+ ActionView::Base.include Basecoat::FormHelper
15
+ end
16
+ end
10
17
  end
11
18
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basecoat
4
- VERSION = "2.2.0"
4
+ VERSION = "2.2.2"
5
5
  end
data/lib/basecoat.rb CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  require_relative "basecoat/version"
4
4
  require_relative "basecoat/railtie" if defined?(Rails)
5
+ require_relative "basecoat/form_builder" if defined?(Rails)
6
+ require_relative "basecoat/form_helper" if defined?(Rails)
7
+
8
+ begin
9
+ require "lucide-rails"
10
+ rescue LoadError
11
+ # lucide-rails is optional but recommended
12
+ end
5
13
 
6
14
  module Basecoat
7
15
  class Error < StandardError; end
@@ -1,8 +1,6 @@
1
1
  <% if resource.errors.any? %>
2
2
  <div class="alert-destructive max-w-2xl mx-auto" data-turbo-cache="false">
3
- <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
4
- <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
5
- </svg>
3
+ <%= lucide_icon "circle-x", class: "w-5 h-5" %>
6
4
  <h2><%= pluralize(resource.errors.count, "error") %> prohibited this <%= resource.class.model_name.human.downcase %> from being saved</h2>
7
5
  <section>
8
6
  <ul>
@@ -1,6 +1,4 @@
1
1
  <div class="alert-destructive max-w-2xl mx-auto" data-turbo-cache="false">
2
- <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
3
- <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
4
- </svg>
2
+ <%= lucide_icon "circle-x", class: "w-5 h-5" %>
5
3
  <h2><%= alert %></h2>
6
4
  </div>
@@ -7,7 +7,7 @@
7
7
  </div>
8
8
  <div class="grid flex-1 text-left text-sm leading-tight">
9
9
  <span class="truncate font-medium">Basecoat</span>
10
- <span class="truncate text-xs">v0.3.3</span>
10
+ <span class="truncate text-xs">v0.3.9</span>
11
11
  </div>
12
12
  </a>
13
13
  </header>
@@ -1,7 +1,5 @@
1
1
  <div class="alert-destructive max-w-2xl mx-auto">
2
- <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
3
- <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
4
- </svg>
2
+ <%= lucide_icon "circle-x", class: "w-5 h-5" %>
5
3
  <h2><%= pluralize(object.errors.count, "error") %> prohibited this object from being saved</h2>
6
4
  <section>
7
5
  <ul>
@@ -1,7 +1,7 @@
1
1
  <header class="bg-background sticky inset-x-0 top-0 isolate flex shrink-0 items-center gap-2 border-b z-10 px-4" data-controller="theme">
2
2
  <div class="flex h-14 flex-1 items-center gap-2">
3
3
  <button type="button" onclick="document.dispatchEvent(new CustomEvent('basecoat:sidebar'))" aria-label="Toggle sidebar" data-tooltip="Toggle sidebar" data-side="bottom" data-align="start" class="btn-sm-icon-ghost mr-auto size-7 -ml-1.5">
4
- <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"></rect><path d="M9 3v18"></path></svg>
4
+ <%= lucide_icon "panel-left" %>
5
5
  </button>
6
6
  <%= render "layouts/theme_toggle" %>
7
7
  <!-- AUTHENTICATION_DROPDOWN -->
@@ -1,10 +1,7 @@
1
1
  <div id="toaster" class="toaster">
2
2
  <div class="toast" role="status" aria-atomic="true" aria-hidden="false" data-category="success">
3
3
  <div class="toast-content">
4
- <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
5
- <circle cx="12" cy="12" r="10" />
6
- <path d="m9 12 2 2 4-4" />
7
- </svg>
4
+ <%= lucide_icon "circle-check" %>
8
5
 
9
6
  <section>
10
7
  <h2><%= notice %></h2>
@@ -1,4 +1,4 @@
1
1
  <button type="button" aria-label="Toggle dark mode" data-tooltip="Toggle dark mode" data-side="left" data-action="click->theme#toggle" class="btn-ghost size-8">
2
- <span class="hidden dark:block"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v2"></path><path d="M12 20v2"></path><path d="m4.93 4.93 1.41 1.41"></path><path d="m17.66 17.66 1.41 1.41"></path><path d="M2 12h2"></path><path d="M20 12h2"></path><path d="m6.34 17.66-1.41 1.41"></path><path d="m19.07 4.93-1.41 1.41"></path></svg></span>
3
- <span class="block dark:hidden"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path></svg></span>
2
+ <span class="hidden dark:block"><%= lucide_icon "sun" %></span>
3
+ <span class="block dark:hidden"><%= lucide_icon "moon" %></span>
4
4
  </button>
@@ -14,11 +14,7 @@ begin
14
14
  link_html = <<~HTML
15
15
  <li>
16
16
  <%= link_to #{plural_table_name}_path, data: { turbo_frame: "main_content", turbo_action: "advance" } do %>
17
- <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
18
- <path d="m7 11 2-2-2-2" />
19
- <path d="M11 13h4" />
20
- <rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
21
- </svg>
17
+ <%= lucide_icon "square-terminal" %>
22
18
  <span>#{human_name.pluralize}</span>
23
19
  <% end %>
24
20
  </li>
@@ -0,0 +1,21 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["input"]
5
+ static values = {
6
+ url: String
7
+ }
8
+
9
+ search(event) {
10
+ const query = event.target.value
11
+ const url = `${this.urlValue}?query=${encodeURIComponent(query)}`
12
+
13
+ fetch(url, {
14
+ headers: {
15
+ Accept: "text/vnd.turbo-stream.html"
16
+ }
17
+ })
18
+ .then(response => response.text())
19
+ .then(html => Turbo.renderStreamMessage(html))
20
+ }
21
+ }
@@ -1,7 +1,7 @@
1
1
  <div class="flex mt-10 flex-1 flex-col items-center justify-center gap-6">
2
2
  <header class="flex max-w-sm flex-col items-center gap-2 text-center">
3
3
  <div class="mb-2 bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg">
4
- <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 10.5 8 13l2 2.5" /><path d="m14 10.5 2 2.5-2 2.5" /><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2z" /></svg>
4
+ <%= lucide_icon "folder-code" %>
5
5
  </div>
6
6
  <h3 class="text-lg font-medium tracking-tight">No <%= resource_name %> yet</h3>
7
7
  <p class="text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4">
@@ -21,12 +21,13 @@ namespace :basecoat do
21
21
 
22
22
  desc "Install Basecoat application layout and partials"
23
23
  task :install do
24
+ no_package_manager = false
24
25
  overwrite_all = { value: false }
25
26
  # Install basecoat-css (detect package manager)
26
27
  puts "\n📦 Installing basecoat-css..."
27
28
 
28
29
  # Detect package manager
29
- if File.exist?(Rails.root.join("bun.lockb"))
30
+ if File.exist?(Rails.root.join("bun.lock"))
30
31
  system("bun add basecoat-css")
31
32
  puts " Installed: basecoat-css via bun"
32
33
  elsif File.exist?(Rails.root.join("yarn.lock"))
@@ -39,9 +40,8 @@ namespace :basecoat do
39
40
  system("pnpm add basecoat-css")
40
41
  puts " Installed: basecoat-css via pnpm"
41
42
  else
42
- # Fallback: try bun first, then yarn, then npm
43
- system("bun add basecoat-css") || system("yarn add basecoat-css") || system("npm install basecoat-css")
44
- puts " Installed: basecoat-css"
43
+ no_package_manager = true
44
+ puts " No package manager detected! We'll insert CDN links into _head.html.erb..."
45
45
  end
46
46
 
47
47
  # If using importmap, also add to importmap.rb for JS
@@ -51,17 +51,10 @@ namespace :basecoat do
51
51
 
52
52
  unless importmap_content.include?("basecoat-css")
53
53
  File.open(importmap_path, "a") do |f|
54
- f.puts "\npin \"basecoat-css/all\", to: \"https://cdn.jsdelivr.net/npm/basecoat-css@0.3.3/dist/js/all.js\""
54
+ f.puts "\npin \"basecoat-css/all\", to: \"https://cdn.jsdelivr.net/npm/basecoat-css@0.3.9/dist/js/all.js\""
55
55
  end
56
56
  puts " Added: basecoat-css to config/importmap.rb"
57
57
  end
58
-
59
- unless importmap_content.include?("basecoat-helper")
60
- File.open(importmap_path, "a") do |f|
61
- f.puts "pin \"basecoat-helper\""
62
- end
63
- puts " Added: basecoat-helper to config/importmap.rb"
64
- end
65
58
  end
66
59
 
67
60
  # Add JavaScript imports and code
@@ -107,6 +100,18 @@ namespace :basecoat do
107
100
  else
108
101
  puts " Skipped: app/javascript/controllers/theme_controller.js"
109
102
  end
103
+
104
+ # Copy search_controller.js
105
+ search_controller_source = File.expand_path("../generators/basecoat/templates/search_controller.js", __dir__)
106
+ search_controller_destination = Rails.root.join("app/javascript/controllers/search_controller.js")
107
+
108
+ FileUtils.mkdir_p(File.dirname(search_controller_destination))
109
+ if prompt_overwrite(search_controller_destination, overwrite_all)
110
+ FileUtils.cp(search_controller_source, search_controller_destination)
111
+ puts " Created: app/javascript/controllers/search_controller.js"
112
+ else
113
+ puts " Skipped: app/javascript/controllers/search_controller.js"
114
+ end
110
115
  end
111
116
 
112
117
  # Add CSS imports and styles
@@ -135,25 +140,25 @@ namespace :basecoat do
135
140
  css_content = File.read(css_path)
136
141
 
137
142
  css_code = <<~CSS
138
- dl {
139
- font-size: var(--text-sm);
140
- dt {
141
- font-weight: var(--font-weight-bold);
142
- margin-top: calc(var(--spacing)*4);
143
- }
144
- }
145
-
146
- label:has(+ input:required):after {
147
- content: " *"
148
- }
149
-
150
- input:user-invalid, .field_with_errors input {
151
- border-color: var(--color-destructive);
152
- }
153
-
154
- label:has(+ input:user-invalid), .field_with_errors label {
155
- color: var(--color-destructive);
156
- }
143
+ dl {
144
+ font-size: var(--text-sm);
145
+ dt {
146
+ font-weight: var(--font-weight-bold);
147
+ margin-top: calc(var(--spacing)*4);
148
+ }
149
+ }
150
+
151
+ label:has(+ input:required):after {
152
+ content: " *"
153
+ }
154
+
155
+ input:user-invalid, .field_with_errors input {
156
+ border-color: var(--color-destructive);
157
+ }
158
+
159
+ label:has(+ input:user-invalid), .field_with_errors label {
160
+ color: var(--color-destructive);
161
+ }
157
162
  CSS
158
163
  File.open(css_path, "a") { |f| f.write(css_code) }
159
164
  puts " Added: basic styles to #{css_path.relative_path_from(Rails.root)}"
@@ -237,6 +242,21 @@ label:has(+ input:user-invalid), .field_with_errors label {
237
242
  end
238
243
  end
239
244
 
245
+ if no_package_manager
246
+ head_path = Rails.root.join("app/views/layouts/_head.html.erb")
247
+ if File.exist?(head_path)
248
+ head_content = File.read(head_path)
249
+ unless head_content.include?("basecoat.cdn.min.css")
250
+ cdn_link = ' <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
251
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/basecoat-css@0.3.9/dist/basecoat.cdn.min.css">'
252
+ # Insert before the closing </head> tag
253
+ updated_content = head_content.sub(/(<\/head>)/, "#{cdn_link}\n\\1")
254
+ File.write(head_path, updated_content)
255
+ puts " Added: CDN link to app/views/layouts/_head.html.erb"
256
+ end
257
+ end
258
+ end
259
+
240
260
  # Copy scaffold hook initializer
241
261
  initializer_source = File.expand_path("../generators/basecoat/templates/scaffold_hook.rb", __dir__)
242
262
  initializer_destination = Rails.root.join("config/initializers/scaffold_hook.rb")
@@ -296,23 +316,23 @@ label:has(+ input:user-invalid), .field_with_errors label {
296
316
  unless header_content.include?("dropdown-user")
297
317
  user_dropdown = <<~HTML
298
318
 
299
- <% if defined?(user_signed_in?) && user_signed_in? %>
300
- <div id="dropdown-user" class="dropdown-menu">
301
- <button type="button" id="dropdown-user-trigger" aria-haspopup="menu" aria-controls="dropdown-user-menu" aria-expanded="false" class="btn-ghost size-8">
302
- <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-user-icon lucide-circle-user"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg>
303
- </button>
304
- <div id="dropdown-user-popover" data-popover="" aria-hidden="true" data-align="end">
305
- <div role="menu" id="dropdown-user-menu" aria-labelledby="dropdown-user-trigger">
306
- <div class="px-1 py-1.5">
307
- <%= button_to destroy_user_session_path, method: :delete, class: "btn-link" do %>
308
- <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
309
- Log out
310
- <% end %>
311
- </div>
312
- </div>
319
+ <% if defined?(user_signed_in?) && user_signed_in? %>
320
+ <div id="dropdown-user" class="dropdown-menu">
321
+ <button type="button" id="dropdown-user-trigger" aria-haspopup="menu" aria-controls="dropdown-user-menu" aria-expanded="false" class="btn-ghost size-8">
322
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-user-icon lucide-circle-user"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg>
323
+ </button>
324
+ <div id="dropdown-user-popover" data-popover="" aria-hidden="true" data-align="end">
325
+ <div role="menu" id="dropdown-user-menu" aria-labelledby="dropdown-user-trigger">
326
+ <div class="px-1 py-1.5">
327
+ <%= button_to destroy_user_session_path, method: :delete, class: "btn-link" do %>
328
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
329
+ Log out
330
+ <% end %>
313
331
  </div>
314
332
  </div>
315
- <% end %>
333
+ </div>
334
+ </div>
335
+ <% end %>
316
336
  HTML
317
337
  updated_content = header_content.sub("<!-- AUTHENTICATION_DROPDOWN -->", user_dropdown)
318
338
  File.write(header_path, updated_content)
@@ -437,23 +457,23 @@ label:has(+ input:user-invalid), .field_with_errors label {
437
457
  unless header_content.include?("dropdown-user")
438
458
  user_dropdown = <<~HTML
439
459
 
440
- <% if defined?(Current) && defined?(Current.user) && Current.user %>
441
- <div id="dropdown-user" class="dropdown-menu">
442
- <button type="button" id="dropdown-user-trigger" aria-haspopup="menu" aria-controls="dropdown-user-menu" aria-expanded="false" class="btn-ghost size-8">
443
- <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-user-icon lucide-circle-user"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg>
444
- </button>
445
- <div id="dropdown-user-popover" data-popover="" aria-hidden="true" data-align="end">
446
- <div role="menu" id="dropdown-user-menu" aria-labelledby="dropdown-user-trigger">
447
- <div class="px-1 py-1.5">
448
- <%= button_to session_path, method: :delete, class: "btn-link" do %>
449
- <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
450
- Log out
451
- <% end %>
452
- </div>
453
- </div>
460
+ <% if defined?(Current) && defined?(Current.user) && Current.user %>
461
+ <div id="dropdown-user" class="dropdown-menu">
462
+ <button type="button" id="dropdown-user-trigger" aria-haspopup="menu" aria-controls="dropdown-user-menu" aria-expanded="false" class="btn-ghost size-8">
463
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-user-icon lucide-circle-user"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg>
464
+ </button>
465
+ <div id="dropdown-user-popover" data-popover="" aria-hidden="true" data-align="end">
466
+ <div role="menu" id="dropdown-user-menu" aria-labelledby="dropdown-user-trigger">
467
+ <div class="px-1 py-1.5">
468
+ <%= button_to session_path, method: :delete, class: "btn-link" do %>
469
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
470
+ Log out
471
+ <% end %>
454
472
  </div>
455
473
  </div>
456
- <% end %>
474
+ </div>
475
+ </div>
476
+ <% end %>
457
477
  HTML
458
478
  updated_content = header_content.sub("<!-- AUTHENTICATION_DROPDOWN -->", user_dropdown)
459
479
  File.write(header_path, updated_content)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: basecoat
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.0
4
+ version: 2.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Martijn Lafeber
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '4.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: lucide-rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.1'
26
40
  description: Provides beautiful, production-ready scaffold templates and Devise views
27
41
  styled with Basecoat CSS framework
28
42
  email:
@@ -43,6 +57,8 @@ files:
43
57
  - basecoat-login.png
44
58
  - basecoat-new.png
45
59
  - lib/basecoat.rb
60
+ - lib/basecoat/form_builder.rb
61
+ - lib/basecoat/form_helper.rb
46
62
  - lib/basecoat/railtie.rb
47
63
  - lib/basecoat/version.rb
48
64
  - lib/generators/basecoat/templates/application.html.erb
@@ -72,6 +88,7 @@ files:
72
88
  - lib/generators/basecoat/templates/passwords/edit.html.erb
73
89
  - lib/generators/basecoat/templates/passwords/new.html.erb
74
90
  - lib/generators/basecoat/templates/scaffold_hook.rb
91
+ - lib/generators/basecoat/templates/search_controller.js
75
92
  - lib/generators/basecoat/templates/sessions.html.erb
76
93
  - lib/generators/basecoat/templates/sessions/new.html.erb
77
94
  - lib/generators/basecoat/templates/shared/_empty.html.erb