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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -2
- data/CONTRIBUTING.md +41 -8
- data/README.md +65 -7
- data/docs/components.md +222 -0
- data/docs/performance_testing.md +34 -0
- data/lib/commands/senren/add/add_command.rb +35 -0
- data/lib/generators/senren/install/templates/base_component.rb.tt +39 -6
- data/lib/generators/senren/install/templates/conventions.md.tt +10 -6
- data/lib/senren/rails/component_copier.rb +75 -6
- data/lib/senren/rails/component_installer.rb +47 -0
- data/lib/senren/rails/installer.rb +1 -0
- data/lib/senren/rails/registry.rb +63 -31
- data/lib/senren/rails/skill_writer.rb +1 -1
- data/lib/senren/rails/version.rb +1 -1
- data/lib/senren/rails.rb +1 -0
- data/lib/tasks/senren.rake +11 -18
- data/templates/components/billing_plan_card/billing_plan_card_component.html.erb +1 -1
- data/templates/components/breadcrumb/breadcrumb_component.rb +2 -2
- data/templates/components/button/button_component.html.erb +1 -1
- data/templates/components/carousel/carousel_component.rb +1 -1
- data/templates/components/command/command_component.rb +1 -1
- data/templates/components/dropdown_menu/dropdown_menu_component.rb +10 -7
- data/templates/components/form/form_component.html.erb +8 -1
- data/templates/components/form/form_component.rb +3 -1
- data/templates/components/input/input_component.html.erb +1 -1
- data/templates/components/input/input_component.rb +19 -0
- data/templates/components/label/label_component.html.erb +1 -2
- data/templates/components/label/label_component.rb +12 -2
- data/templates/components/link/link_component.html.erb +1 -1
- data/templates/components/native_select/native_select_component.html.erb +19 -5
- data/templates/components/native_select/native_select_component.rb +17 -5
- data/templates/components/pagination/pagination_component.rb +2 -1
- data/templates/components/sidebar/sidebar_component.rb +2 -2
- data/templates/components/switch/switch_component.html.erb +2 -2
- data/templates/components/top_nav/top_nav_component.rb +2 -2
- data/templates/controllers/rich_text_editor_lite_controller.js +12 -2
- 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
|
|
122
|
+
if component_source_path?(comp, relative)
|
|
63
123
|
File.join(Senren::Rails.templates_root, 'components', comp.name, base)
|
|
64
|
-
elsif
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
53
|
+
return '_No Senren components installed yet. Run `bin/rails senren:add button` to install components._'
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
lines = []
|
data/lib/senren/rails/version.rb
CHANGED
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'
|
data/lib/tasks/senren.rake
CHANGED
|
@@ -3,22 +3,19 @@
|
|
|
3
3
|
require 'senren/rails'
|
|
4
4
|
|
|
5
5
|
namespace :senren do
|
|
6
|
-
desc 'Install one or more Senren components
|
|
7
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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 <
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
<%=
|
|
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
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
29
|
-
|
|
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
|
-
'
|
|
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,
|