senren-ui 0.1.5 → 0.1.6

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -2
  3. data/CONTRIBUTING.md +41 -8
  4. data/README.md +65 -7
  5. data/docs/components.md +222 -0
  6. data/docs/performance_testing.md +34 -0
  7. data/lib/commands/senren/add/add_command.rb +35 -0
  8. data/lib/generators/senren/install/templates/base_component.rb.tt +39 -6
  9. data/lib/generators/senren/install/templates/conventions.md.tt +10 -6
  10. data/lib/senren/rails/component_copier.rb +75 -6
  11. data/lib/senren/rails/component_installer.rb +47 -0
  12. data/lib/senren/rails/installer.rb +1 -0
  13. data/lib/senren/rails/registry.rb +63 -31
  14. data/lib/senren/rails/skill_writer.rb +1 -1
  15. data/lib/senren/rails/version.rb +1 -1
  16. data/lib/senren/rails.rb +1 -0
  17. data/lib/tasks/senren.rake +11 -18
  18. data/templates/components/billing_plan_card/billing_plan_card_component.html.erb +1 -1
  19. data/templates/components/breadcrumb/breadcrumb_component.rb +2 -2
  20. data/templates/components/button/button_component.html.erb +1 -1
  21. data/templates/components/carousel/carousel_component.rb +1 -1
  22. data/templates/components/command/command_component.rb +1 -1
  23. data/templates/components/dropdown_menu/dropdown_menu_component.rb +10 -7
  24. data/templates/components/form/form_component.html.erb +8 -1
  25. data/templates/components/form/form_component.rb +3 -1
  26. data/templates/components/input/input_component.html.erb +1 -1
  27. data/templates/components/input/input_component.rb +19 -0
  28. data/templates/components/label/label_component.html.erb +1 -2
  29. data/templates/components/label/label_component.rb +12 -2
  30. data/templates/components/link/link_component.html.erb +1 -1
  31. data/templates/components/native_select/native_select_component.html.erb +19 -5
  32. data/templates/components/native_select/native_select_component.rb +17 -5
  33. data/templates/components/pagination/pagination_component.rb +2 -1
  34. data/templates/components/sidebar/sidebar_component.rb +2 -2
  35. data/templates/components/switch/switch_component.html.erb +2 -2
  36. data/templates/components/top_nav/top_nav_component.rb +2 -2
  37. data/templates/controllers/rich_text_editor_lite_controller.js +12 -2
  38. metadata +22 -4
@@ -9,6 +9,47 @@ module Senren
9
9
  # Copies component files from the gem's templates/ tree into the host
10
10
  # Rails app, and updates .senren/installed_components.yml.
11
11
  class ComponentCopier
12
+ class MissingTemplate < StandardError; end
13
+
14
+ INSTALL_GENERATOR_TEMPLATES = File.expand_path(
15
+ '../../generators/senren/install/templates', __dir__
16
+ ).freeze
17
+ BASE_COMPONENT_TEMPLATE = File.join(INSTALL_GENERATOR_TEMPLATES, 'base_component.rb.tt').freeze
18
+ BASE_URL_HELPER_PATCH = <<~RUBY
19
+
20
+ # Added by senren:add for compatibility with URL-aware component templates.
21
+ require 'uri'
22
+
23
+ module Senren
24
+ class BaseComponent
25
+ SAFE_URL_PROTOCOLS = %w[http https mailto tel].freeze unless const_defined?(:SAFE_URL_PROTOCOLS)
26
+ SAFE_MEDIA_URL_PROTOCOLS = %w[http https].freeze unless const_defined?(:SAFE_MEDIA_URL_PROTOCOLS)
27
+
28
+ private
29
+
30
+ def safe_url(value, fallback: '#', protocols: SAFE_URL_PROTOCOLS)
31
+ url = value.to_s.strip
32
+ return fallback if url.empty?
33
+ return url if url.start_with?('#')
34
+ return url if url.start_with?('/') && !url.start_with?('//')
35
+
36
+ uri = URI.parse(url)
37
+ return url if uri.scheme && Array(protocols).map(&:to_s).include?(uri.scheme.downcase)
38
+ return fallback if uri.host
39
+ return url unless uri.scheme
40
+
41
+ fallback
42
+ rescue URI::InvalidURIError
43
+ fallback
44
+ end
45
+
46
+ def safe_media_url(value, fallback: nil)
47
+ safe_url(value, fallback: fallback, protocols: SAFE_MEDIA_URL_PROTOCOLS)
48
+ end
49
+ end
50
+ end
51
+ RUBY
52
+
12
53
  attr_reader :registry, :paths, :stdout
13
54
 
14
55
  def initialize(registry: Registry.load!, paths: HostPaths.new, stdout: $stdout)
@@ -22,6 +63,7 @@ module Senren
22
63
  def install(component_names, client_override: nil, force: false)
23
64
  wanted = registry.dependencies(*component_names)
24
65
  paths.ensure_dirs!
66
+ ensure_base_component_url_helpers!
25
67
 
26
68
  wanted.each do |name|
27
69
  comp = registry.fetch(name)
@@ -34,6 +76,24 @@ module Senren
34
76
 
35
77
  private
36
78
 
79
+ def ensure_base_component_url_helpers!
80
+ if paths.base_component_path.exist?
81
+ return if base_component_has_url_helpers?
82
+
83
+ File.open(paths.base_component_path, 'a') { |file| file.write(BASE_URL_HELPER_PATCH) }
84
+ stdout.puts " update #{paths.base_component_path} (url helpers)"
85
+ return
86
+ end
87
+
88
+ copy_file(BASE_COMPONENT_TEMPLATE, paths.base_component_path, force: false, label: 'base_component.rb')
89
+ end
90
+
91
+ def base_component_has_url_helpers?
92
+ source = paths.base_component_path.read
93
+
94
+ source.include?('def safe_url') && source.include?('def safe_media_url')
95
+ end
96
+
37
97
  def install_component(comp, client_override:, force:)
38
98
  effective_client = effective_client_for(comp, client_override)
39
99
 
@@ -59,20 +119,29 @@ module Senren
59
119
  # app/components/senren/<name>_component.html.erb -> templates/components/<name>/<name>_component.html.erb
60
120
  # app/javascript/controllers/senren/<name>_controller.js -> templates/controllers/<name>_controller.js
61
121
  base = File.basename(relative)
62
- if relative.include?('app/components/senren/')
122
+ if component_source_path?(comp, relative)
63
123
  File.join(Senren::Rails.templates_root, 'components', comp.name, base)
64
- elsif relative.include?('app/javascript/controllers/senren/')
124
+ elsif controller_source_path?(comp, relative)
65
125
  File.join(Senren::Rails.templates_root, 'controllers', base)
66
126
  else
67
127
  raise "ComponentCopier: do not know how to map #{relative.inspect}"
68
128
  end
69
129
  end
70
130
 
131
+ def component_source_path?(comp, relative)
132
+ [
133
+ "app/components/senren/#{comp.name}_component.rb",
134
+ "app/components/senren/#{comp.name}_component.html.erb"
135
+ ].include?(relative)
136
+ end
137
+
138
+ def controller_source_path?(comp, relative)
139
+ relative == "app/javascript/controllers/senren/#{comp.name}_controller.js"
140
+ end
141
+
71
142
  def copy_file(src, dest, force:, label:)
72
- unless File.exist?(src)
73
- stdout.puts " warn missing template: #{src} (#{label})"
74
- return
75
- end
143
+ raise MissingTemplate, "Missing component template: #{src} (#{label})" unless File.exist?(src)
144
+
76
145
  if File.exist?(dest) && !force
77
146
  stdout.puts " skip #{dest} (already exists)"
78
147
  return
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'senren/rails/agent_rules_writer'
4
+ require 'senren/rails/component_copier'
5
+ require 'senren/rails/host_paths'
6
+ require 'senren/rails/registry'
7
+ require 'senren/rails/skill_writer'
8
+
9
+ module Senren
10
+ module Rails
11
+ # Installs one or more registered Senren components into the host app and
12
+ # refreshes the generated guidance files afterward.
13
+ class ComponentInstaller
14
+ USAGE = 'Usage: bin/rails senren:add NAME [NAME...] [--client | --no-client]'
15
+
16
+ attr_reader :registry, :paths, :stdout
17
+
18
+ def self.normalize_names(names)
19
+ Array(names)
20
+ .flatten
21
+ .flat_map { |entry| entry.to_s.split(/[,\s]+/) }
22
+ .reject { |entry| entry.empty? || entry.start_with?('-') }
23
+ .uniq
24
+ end
25
+
26
+ def initialize(registry: Registry.load!, paths: HostPaths.new, stdout: $stdout)
27
+ @registry = registry
28
+ @paths = paths
29
+ @stdout = stdout
30
+ end
31
+
32
+ def install(names:, client_override: nil, force: false)
33
+ normalized_names = self.class.normalize_names(names)
34
+ raise ArgumentError, USAGE if normalized_names.empty?
35
+
36
+ installed = ComponentCopier.new(registry: registry, paths: paths, stdout: stdout)
37
+ .install(normalized_names, client_override: client_override, force: force)
38
+
39
+ SkillWriter.new(registry: registry, paths: paths).sync!
40
+ AgentRulesWriter.new(registry: registry, paths: paths).sync!
41
+
42
+ stdout.puts "Installed: #{installed.join(', ')}"
43
+ installed
44
+ end
45
+ end
46
+ end
47
+ end
@@ -73,6 +73,7 @@ module Senren
73
73
 
74
74
  bin/rails senren:add button card badge alert
75
75
  bin/rails senren:add dialog dropdown_menu
76
+ bundle exec rails senren:add form input textarea
76
77
  bin/rails senren:agents:sync
77
78
  bin/rails senren:doctor
78
79
 
@@ -3,23 +3,16 @@ require 'yaml'
3
3
  module Senren
4
4
  module Rails
5
5
  # Loads, validates, and queries the Senren component registry.
6
- #
7
- # reg = Senren::Rails::Registry.load!
8
- # reg.find("button") # => Component struct
9
- # reg.dependencies("dialog") # => [<button>]
10
- # reg.group("forms") # => [<form>, <input>, ...]
11
6
  class Registry
12
7
  include Enumerable
13
8
 
14
9
  REQUIRED_KEYS = %w[category client can_have_client files depends_on pairs_with variants accessibility ai].freeze
10
+ OPTIONAL_KEYS = %w[controller stub].freeze
11
+ ALLOWED_KEYS = (REQUIRED_KEYS + OPTIONAL_KEYS).freeze
15
12
  VALID_CATEGORIES = %w[actions forms overlays navigation layout data saas rich].freeze
16
13
 
17
- Component = Struct.new(
18
- :name, :category, :client, :can_have_client, :controller, :stub,
19
- :files, :depends_on, :pairs_with, :variants, :accessibility,
20
- :use_for, :avoid,
21
- keyword_init: true
22
- ) do
14
+ Component = Struct.new(:name, :category, :client, :can_have_client, :controller, :stub, :files, :depends_on,
15
+ :pairs_with, :variants, :accessibility, :use_for, :avoid, keyword_init: true) do
23
16
  def stub? = stub == true
24
17
  def client? = client == true
25
18
 
@@ -43,6 +36,7 @@ module Senren
43
36
  end
44
37
 
45
38
  def initialize(components_yaml, groups_yaml, recipes_yaml)
39
+ @raw_components = (components_yaml || {}).fetch('components', {})
46
40
  @components = parse_components(components_yaml)
47
41
  @groups = (groups_yaml || {}).fetch('groups', [])
48
42
  @recipes = (recipes_yaml || {}).fetch('recipes', {})
@@ -57,21 +51,11 @@ module Senren
57
51
  "Known: #{@components.keys.sort.join(', ')}"
58
52
  end
59
53
 
60
- def all
61
- @components.values
62
- end
63
-
64
- def each(&)
65
- all.each(&)
66
- end
54
+ def all = @components.values
55
+ def each(&) = all.each(&)
56
+ alias find_each each
67
57
 
68
- def find_each(&)
69
- each(&)
70
- end
71
-
72
- def names
73
- @components.keys
74
- end
58
+ def names = @components.keys
75
59
 
76
60
  def group(category_id)
77
61
  @components.values.select { |c| c.category == category_id.to_s }
@@ -118,12 +102,48 @@ module Senren
118
102
 
119
103
  def validate_components(errors)
120
104
  @components.each do |name, comp|
121
- errors << "#{name}: invalid category #{comp.category.inspect}" unless VALID_CATEGORIES.include?(comp.category)
122
- comp.depends_on.each do |dep|
123
- errors << "#{name}: depends_on unknown component #{dep.inspect}" unless @components.key?(dep)
124
- end
125
- errors << "#{name}: client=true but can_have_client=false" if comp.client && !comp.can_have_client
126
- errors << "#{name}: client=true requires a controller identifier" if comp.client && comp.controller.nil?
105
+ validate_component(name, comp, errors)
106
+ end
107
+ end
108
+
109
+ def validate_component(name, comp, errors)
110
+ validate_component_keys(name, errors)
111
+ validate_component_category(name, comp, errors)
112
+ validate_component_dependencies(name, comp, errors)
113
+ validate_component_client_contract(name, comp, errors)
114
+ validate_component_file_paths(name, comp, errors)
115
+ end
116
+
117
+ def validate_component_keys(name, errors)
118
+ extra_keys = @raw_components.fetch(name).keys - ALLOWED_KEYS
119
+ errors << "#{name}: unknown keys #{extra_keys.sort.join(', ')}" if extra_keys.any?
120
+ end
121
+
122
+ def validate_component_category(name, comp, errors)
123
+ return if VALID_CATEGORIES.include?(comp.category)
124
+
125
+ errors << "#{name}: invalid category #{comp.category.inspect}"
126
+ end
127
+
128
+ def validate_component_dependencies(name, comp, errors)
129
+ comp.depends_on.each do |dep|
130
+ errors << "#{name}: depends_on unknown component #{dep.inspect}" unless @components.key?(dep)
131
+ end
132
+ end
133
+
134
+ def validate_component_client_contract(name, comp, errors)
135
+ errors << "#{name}: client=true but can_have_client=false" if comp.client && !comp.can_have_client
136
+ errors << "#{name}: client=true requires a controller identifier" if comp.client && comp.controller.nil?
137
+ return unless comp.client && !controller_file?(name, comp)
138
+
139
+ errors << "#{name}: client=true requires a Stimulus controller file"
140
+ end
141
+
142
+ def validate_component_file_paths(name, comp, errors)
143
+ comp.files.each do |path|
144
+ next if allowed_component_file?(name, path)
145
+
146
+ errors << "#{name}: invalid file path #{path.inspect}"
127
147
  end
128
148
  end
129
149
 
@@ -156,6 +176,18 @@ module Senren
156
176
  ).freeze
157
177
  end
158
178
  end
179
+
180
+ def controller_file?(name, comp)
181
+ comp.files.include?("app/javascript/controllers/senren/#{name}_controller.js")
182
+ end
183
+
184
+ def allowed_component_file?(name, path)
185
+ [
186
+ "app/components/senren/#{name}_component.rb",
187
+ "app/components/senren/#{name}_component.html.erb",
188
+ "app/javascript/controllers/senren/#{name}_controller.js"
189
+ ].include?(path)
190
+ end
159
191
  end
160
192
  end
161
193
  end
@@ -50,7 +50,7 @@ module Senren
50
50
 
51
51
  def render(installed_names)
52
52
  if installed_names.empty?
53
- return '_No Senren components installed yet. Run `bin/rails senren:add <name>...` to install components._'
53
+ return '_No Senren components installed yet. Run `bin/rails senren:add button` to install components._'
54
54
  end
55
55
 
56
56
  lines = []
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Senren
4
4
  module Rails
5
- VERSION = '0.1.5'
5
+ VERSION = '0.1.6'
6
6
  end
7
7
  end
data/lib/senren/rails.rb CHANGED
@@ -8,6 +8,7 @@ module Senren
8
8
 
9
9
  autoload :Registry, 'senren/rails/registry'
10
10
  autoload :ComponentCopier, 'senren/rails/component_copier'
11
+ autoload :ComponentInstaller, 'senren/rails/component_installer'
11
12
  autoload :SkillWriter, 'senren/rails/skill_writer'
12
13
  autoload :AgentRulesWriter, 'senren/rails/agent_rules_writer'
13
14
  autoload :LlmsWriter, 'senren/rails/llms_writer'
@@ -3,22 +3,19 @@
3
3
  require 'senren/rails'
4
4
 
5
5
  namespace :senren do
6
- desc 'Install one or more Senren components: rake senren:add[button,dialog] or bin/rails senren:add button dialog'
7
- task :add, [:names] => :environment do |_t, args|
6
+ desc 'Install one or more Senren components. Preferred: bin/rails senren:add button dialog. ' \
7
+ "Legacy: bin/rails 'senren:add[button,dialog]'"
8
+ task :add, [:names] do |_t, args|
8
9
  names = parse_names(args)
9
10
  options = parse_options
10
- abort 'Usage: bin/rails senren:add NAME [NAME...] [--client | --no-client]' if names.empty?
11
11
 
12
- paths = Senren::Rails::HostPaths.new
13
- registry = Senren::Rails::Registry.load!
14
- copier = Senren::Rails::ComponentCopier.new(registry: registry, paths: paths)
15
-
16
- installed = copier.install(names, client_override: options[:client_override], force: options[:force])
17
-
18
- Senren::Rails::SkillWriter.new(registry: registry, paths: paths).sync!
19
- Senren::Rails::AgentRulesWriter.new(registry: registry, paths: paths).sync!
20
-
21
- puts "Installed: #{installed.join(', ')}"
12
+ Senren::Rails::ComponentInstaller.new.install(
13
+ names: names,
14
+ client_override: options[:client_override],
15
+ force: options[:force]
16
+ )
17
+ rescue ArgumentError => e
18
+ abort e.message
22
19
  end
23
20
 
24
21
  namespace :skill do
@@ -67,11 +64,7 @@ def parse_names(args)
67
64
  raw.concat(args.extras)
68
65
  raw << args[:names] if args[:names]
69
66
  raw.concat(ARGV.drop_while { |a| !a.start_with?('senren:') }.drop(1))
70
- raw
71
- .flatten
72
- .flat_map { |s| s.to_s.split(/[,\s]+/) }
73
- .reject { |s| s.empty? || s.start_with?('-') }
74
- .uniq
67
+ Senren::Rails::ComponentInstaller.normalize_names(raw)
75
68
  end
76
69
 
77
70
  def parse_options
@@ -22,7 +22,7 @@
22
22
  </ul>
23
23
  <% end %>
24
24
  <% if cta_label.present? %>
25
- <%= link_to cta_label, cta_href || "#", class: "mt-6 inline-flex h-10 w-full items-center justify-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-4 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90" %>
25
+ <%= link_to cta_label, safe_url(cta_href), class: "mt-6 inline-flex h-10 w-full items-center justify-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-4 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90" %>
26
26
  <% end %>
27
27
  <% end %>
28
28
  <% end %>
@@ -19,10 +19,10 @@ module Senren
19
19
  def normalize_items(items)
20
20
  Array(items).map do |item|
21
21
  if item.is_a?(Hash)
22
- { label: item.fetch(:label) { item.fetch('label') }, href: item[:href] || item['href'] }
22
+ { label: item.fetch(:label) { item.fetch('label') }, href: safe_url(item[:href] || item['href'], fallback: nil) }
23
23
  else
24
24
  label, href = item
25
- { label: label, href: href }
25
+ { label: label, href: safe_url(href, fallback: nil) }
26
26
  end
27
27
  end
28
28
  end
@@ -1,6 +1,6 @@
1
1
  <% base = "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-(--senren-radius) font-medium transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))] disabled:opacity-50 disabled:pointer-events-none disabled:cursor-not-allowed" %>
2
2
  <% if as == :a %>
3
- <%= tag.a content, **root_attrs(base, href: href) %>
3
+ <%= tag.a content, **root_attrs(base, href: safe_url(href)) %>
4
4
  <% else %>
5
5
  <%= tag.button content, **root_attrs(base, type: type) %>
6
6
  <% end %>
@@ -21,7 +21,7 @@ module Senren
21
21
  {
22
22
  title: slide[:title] || slide['title'],
23
23
  description: slide[:description] || slide['description'],
24
- image_url: slide[:image_url] || slide['image_url'],
24
+ image_url: safe_media_url(slide[:image_url] || slide['image_url']),
25
25
  alt: slide[:alt] || slide['alt'],
26
26
  badge: slide[:badge] || slide['badge']
27
27
  }
@@ -29,7 +29,7 @@ module Senren
29
29
  id: data[:id] || data['id'] || "#{dom_id}-option-#{index}",
30
30
  label: label,
31
31
  description: description,
32
- href: data[:href] || data['href'],
32
+ href: safe_url(data[:href] || data['href'], fallback: nil),
33
33
  keywords: [label, description, keywords].flatten.compact.join(' ')
34
34
  }
35
35
  end
@@ -12,23 +12,26 @@ module Senren
12
12
  super(variant: :default, size: :md, class_name: class_name, **html)
13
13
  end
14
14
 
15
- class ItemTag < ViewComponent::Base
16
- def initialize(href: nil, method: nil, destructive: false, **opts)
15
+ class ItemTag < BaseComponent
16
+ VARIANTS = { default: '' }.freeze
17
+ SIZES = { md: '' }.freeze
18
+ ITEM_ACTION = 'click->senren--dropdown-menu#close keydown->senren--dropdown-menu#onItemKey'
19
+
20
+ def initialize(href: nil, method: nil, destructive: false, class_name: nil, **)
21
+ super(variant: :default, size: :md, class_name: class_name, **)
17
22
  @href = href
18
23
  @method = method
19
24
  @destructive = destructive
20
- @opts = opts
21
25
  end
22
26
 
23
27
  def call
24
28
  klass = 'block w-full text-left px-3 py-2 text-sm rounded-sm hover:bg-[hsl(var(--senren-accent))] focus:bg-[hsl(var(--senren-accent))] outline-none cursor-pointer'
29
+ klass += " #{class_name}" if class_name.present?
25
30
  klass += ' text-[hsl(var(--senren-destructive))]' if @destructive
26
31
  if @href
27
- link_to(content, @href, role: 'menuitem', method: @method, class: klass,
28
- data: { action: 'click->senren--dropdown-menu#close keydown->senren--dropdown-menu#onItemKey' })
32
+ link_to(content, safe_url(@href), role: 'menuitem', method: @method, class: klass, data: { action: ITEM_ACTION }, **html_attrs)
29
33
  else
30
- tag.button(content, type: 'button', role: 'menuitem', class: klass,
31
- data: { action: 'click->senren--dropdown-menu#close keydown->senren--dropdown-menu#onItemKey' }, **@opts)
34
+ tag.button(content, type: 'button', role: 'menuitem', class: klass, data: { action: ITEM_ACTION }, **html_attrs)
32
35
  end
33
36
  end
34
37
  end
@@ -1,3 +1,10 @@
1
- <%= form_with(model: model, url: url, method: method, multipart: multipart, **root_attrs("space-y-4")) do |f| %>
1
+ <%
2
+ form_opts = {}
3
+ form_opts[:model] = model if model
4
+ form_opts[:url] = url if url
5
+ form_opts[:method] = method if method
6
+ form_opts[:multipart] = multipart if multipart
7
+ %>
8
+ <%= form_with(**form_opts, **root_attrs("space-y-4")) do |f| %>
2
9
  <%= content || capture(f, &Proc.new) %>
3
10
  <% end %>
@@ -5,7 +5,9 @@ module Senren
5
5
  VARIANTS = { default: '' }.freeze
6
6
  SIZES = { md: '' }.freeze
7
7
 
8
- def initialize(model: nil, url: nil, method: :post, multipart: false, class_name: nil, **html)
8
+ # method: defaults to nil so Rails can infer PATCH for persisted models.
9
+ # Pass method: :post / :patch / :delete explicitly only when needed.
10
+ def initialize(model: nil, url: nil, method: nil, multipart: false, class_name: nil, **html)
9
11
  super(variant: :default, size: :md, class_name: class_name, **html)
10
12
  @model = model
11
13
  @url = url
@@ -1 +1 @@
1
- <%= tag.input(**root_attrs("flex w-full rounded-(--senren-radius) border bg-[hsl(var(--senren-background))] text-[hsl(var(--senren-foreground))] file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-[hsl(var(--senren-muted-foreground))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50", id: id, name: name, type: type, value: value, placeholder: placeholder, "aria-invalid": variant == :error)) %>
1
+ <%= tag.input(**root_attrs(input_classes, id: id, name: name, type: type, value: value, placeholder: placeholder, "aria-invalid": variant == :error)) %>
@@ -13,6 +13,17 @@ module Senren
13
13
  lg: 'h-12 text-base px-4'
14
14
  }.freeze
15
15
 
16
+ # Base classes shared by all input types.
17
+ # NOTE: `flex` is intentionally omitted — it breaks browser-native
18
+ # date/datetime-local/time picker UI on some engines.
19
+ BASE_CLASSES = 'w-full rounded-(--senren-radius) border bg-[hsl(var(--senren-background))] text-[hsl(var(--senren-foreground))] placeholder:text-[hsl(var(--senren-muted-foreground))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50'
20
+
21
+ # File-type inputs get a styled button instead of unstyled browser defaults.
22
+ FILE_CLASSES = 'file:mr-3 file:h-full file:cursor-pointer file:border-0 file:border-r file:border-solid file:border-[hsl(var(--senren-border))] file:bg-[hsl(var(--senren-muted))] file:px-3 file:text-sm file:font-medium file:text-[hsl(var(--senren-foreground))]'
23
+
24
+ # Non-file inputs get standard font styling for the placeholder.
25
+ TEXT_FILE_CLASSES = 'file:border-0 file:bg-transparent file:text-sm file:font-medium'
26
+
16
27
  def initialize(name:, type: 'text', value: nil, placeholder: nil, id: nil, variant: :default, size: :md,
17
28
  class_name: nil, **html)
18
29
  super(variant: variant, size: size, class_name: class_name, **html)
@@ -24,5 +35,13 @@ module Senren
24
35
  end
25
36
 
26
37
  attr_reader :name, :type, :value, :placeholder, :id
38
+
39
+ def input_classes
40
+ file_type? ? "#{BASE_CLASSES} #{FILE_CLASSES}" : "#{BASE_CLASSES} #{TEXT_FILE_CLASSES}"
41
+ end
42
+
43
+ def file_type?
44
+ type.to_s == 'file'
45
+ end
27
46
  end
28
47
  end
@@ -1,4 +1,3 @@
1
1
  <%= tag.label(**root_attrs("text-sm font-medium leading-none text-[hsl(var(--senren-foreground))] peer-disabled:cursor-not-allowed peer-disabled:opacity-70", for: for_field)) do %>
2
- <%= content %>
3
- <% if variant == :required %><span class="text-[hsl(var(--senren-destructive))]" aria-hidden="true"> *</span><% end %>
2
+ <%= label_text %><% if variant == :required %><span class="text-[hsl(var(--senren-destructive))]" aria-hidden="true"> *</span><% end %>
4
3
  <% end %>
@@ -9,11 +9,21 @@ module Senren
9
9
 
10
10
  SIZES = { md: '' }.freeze
11
11
 
12
- def initialize(for_field:, variant: :default, class_name: nil, **html)
12
+ # Accept optional text: param as fallback for inline block syntax.
13
+ # This ensures both patterns work reliably:
14
+ # render(Senren::LabelComponent.new(for_field: "name", text: "Name"))
15
+ # render(Senren::LabelComponent.new(for_field: "name")) { "Name" }
16
+ def initialize(for_field:, text: nil, variant: :default, class_name: nil, **html)
13
17
  super(variant: variant, size: :md, class_name: class_name, **html)
14
18
  @for_field = for_field
19
+ @text = text
15
20
  end
16
21
 
17
- attr_reader :for_field
22
+ attr_reader :for_field, :text
23
+
24
+ # Resolve label text: content block > text param > empty string
25
+ def label_text
26
+ content.presence || @text || ''
27
+ end
18
28
  end
19
29
  end
@@ -1 +1 @@
1
- <%= tag.a content, **root_attrs("inline-flex items-center gap-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))] rounded-sm", href: href, **external_attrs) %>
1
+ <%= tag.a content, **root_attrs("inline-flex items-center gap-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))] rounded-sm", href: safe_url(href), **external_attrs) %>
@@ -1,5 +1,6 @@
1
- <%= tag.div(**wrapper_attrs) do %>
2
- <%= tag.select(**select_attrs) do %>
1
+ <% if native_arrow? %>
2
+ <%# Native mode: no wrapper div, no SVG arrow — let the browser render its own. %>
3
+ <%= tag.select(**root_select_attrs) do %>
3
4
  <% if prompt %>
4
5
  <option value=""><%= prompt %></option>
5
6
  <% end %>
@@ -8,7 +9,20 @@
8
9
  <option value="<%= value %>" <%= "selected" if selected.to_s == value.to_s %>><%= label %></option>
9
10
  <% end %>
10
11
  <% end %>
11
- <svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[hsl(var(--senren-muted-foreground))] transition-transform duration-150 group-focus-within:rotate-180">
12
- <path d="m5 8 5 5 5-5"></path>
13
- </svg>
12
+ <% else %>
13
+ <%# Custom arrow mode: wrapper div + positioned SVG. %>
14
+ <%= tag.div(class: "group relative w-full", data: { senren_component: senren_component_name }) do %>
15
+ <%= tag.select(**select_attrs) do %>
16
+ <% if prompt %>
17
+ <option value=""><%= prompt %></option>
18
+ <% end %>
19
+ <% options.each do |opt| %>
20
+ <% value, label = Array === opt ? opt : [opt, opt] %>
21
+ <option value="<%= value %>" <%= "selected" if selected.to_s == value.to_s %>><%= label %></option>
22
+ <% end %>
23
+ <% end %>
24
+ <svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[hsl(var(--senren-muted-foreground))] transition-transform duration-150 group-focus-within:rotate-180">
25
+ <path d="m5 8 5 5 5-5"></path>
26
+ </svg>
27
+ <% end %>
14
28
  <% end %>
@@ -13,20 +13,23 @@ module Senren
13
13
  lg: 'h-12 text-base px-4'
14
14
  }.freeze
15
15
 
16
- def initialize(name:, options:, selected: nil, id: nil, prompt: nil, variant: :default, size: :md, class_name: nil,
17
- **html)
16
+ # native_arrow: true → keep browser/OS native arrow (appearance-auto)
17
+ # native_arrow: false → use custom SVG arrow (appearance-none + SVG overlay)
18
+ def initialize(name:, options:, selected: nil, id: nil, prompt: nil, native_arrow: true,
19
+ variant: :default, size: :md, class_name: nil, **html)
18
20
  super(variant: variant, size: size, class_name: class_name, **html)
19
21
  @name = name
20
22
  @options = options
21
23
  @selected = selected
22
24
  @id = id || name.to_s.parameterize
23
25
  @prompt = prompt
26
+ @native_arrow = native_arrow
24
27
  end
25
28
 
26
29
  attr_reader :name, :options, :selected, :id, :prompt
27
30
 
28
- def wrapper_attrs
29
- { class: 'group relative w-full', data: { senren_component: senren_component_name } }
31
+ def native_arrow?
32
+ @native_arrow
30
33
  end
31
34
 
32
35
  def select_attrs
@@ -39,9 +42,18 @@ module Senren
39
42
  )
40
43
  end
41
44
 
45
+ def root_select_attrs
46
+ attrs = select_attrs
47
+ data = (attrs[:data] || {}).merge(senren_component: senren_component_name)
48
+
49
+ attrs.merge(data: data)
50
+ end
51
+
42
52
  def select_classes
53
+ appearance = native_arrow? ? 'appearance-auto' : 'appearance-none pr-9'
43
54
  [
44
- 'flex w-full cursor-pointer appearance-none rounded-(--senren-radius) border bg-[hsl(var(--senren-background))] pr-9 text-[hsl(var(--senren-foreground))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
55
+ 'w-full cursor-pointer rounded-(--senren-radius) border bg-[hsl(var(--senren-background))] text-[hsl(var(--senren-foreground))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
56
+ appearance,
45
57
  self.class::VARIANTS[variant],
46
58
  self.class::SIZES[size],
47
59
  class_name,