senren-ui 0.1.4 → 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 +49 -2
- data/CONTRIBUTING.md +41 -8
- data/README.md +73 -11
- 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/install_generator.rb +4 -7
- data/lib/generators/senren/install/templates/base_component.rb.tt +39 -6
- data/lib/generators/senren/install/templates/conventions.md.tt +22 -8
- data/lib/senren/rails/agent_rules_writer.rb +175 -0
- data/lib/senren/rails/component_copier.rb +75 -6
- data/lib/senren/rails/component_installer.rb +47 -0
- data/lib/senren/rails/doctor.rb +26 -13
- data/lib/senren/rails/host_paths.rb +12 -3
- data/lib/senren/rails/installer.rb +4 -2
- data/lib/senren/rails/llms_writer.rb +5 -132
- 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 +2 -0
- data/lib/tasks/senren.rake +26 -21
- 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 +23 -4
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'yaml'
|
|
4
|
-
|
|
5
3
|
module Senren
|
|
6
4
|
module Rails
|
|
7
|
-
#
|
|
8
|
-
#
|
|
5
|
+
# Backward-compatible wrapper around AgentRulesWriter.
|
|
6
|
+
#
|
|
7
|
+
# Legacy llms generation now maps to agent rules synchronization and no longer
|
|
8
|
+
# writes public llms files.
|
|
9
9
|
class LlmsWriter
|
|
10
10
|
attr_reader :registry, :paths
|
|
11
11
|
|
|
@@ -15,134 +15,7 @@ module Senren
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def generate!
|
|
18
|
-
paths.
|
|
19
|
-
paths.llms_full.parent.mkpath
|
|
20
|
-
atomic_write(paths.llms_short, render_short)
|
|
21
|
-
atomic_write(paths.llms_full, render_full)
|
|
22
|
-
[paths.llms_short, paths.llms_full]
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
private
|
|
26
|
-
|
|
27
|
-
def installed
|
|
28
|
-
path = paths.installed_components
|
|
29
|
-
return [] unless path.exist?
|
|
30
|
-
|
|
31
|
-
ledger = YAML.safe_load_file(path) || {}
|
|
32
|
-
Array(ledger['installed']).filter_map { |e| registry.find(e['name']) }
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def render_short
|
|
36
|
-
names = installed.map(&:name).sort
|
|
37
|
-
<<~TXT
|
|
38
|
-
# Senren UI
|
|
39
|
-
|
|
40
|
-
Generated by senren-ui. Do not edit by hand. Run
|
|
41
|
-
`bin/rails senren:llms:generate` to regenerate.
|
|
42
|
-
|
|
43
|
-
Senren UI is the local Rails UI system used by this application.
|
|
44
|
-
Use `.senren/skill.md` as the primary AI Agent guide.
|
|
45
|
-
|
|
46
|
-
## Hard Rules
|
|
47
|
-
|
|
48
|
-
- Use Senren components before writing custom HTML.
|
|
49
|
-
- Use ViewComponent for reusable UI.
|
|
50
|
-
- Use Turbo for server state.
|
|
51
|
-
- Use Stimulus only for local behavior.
|
|
52
|
-
- Do not introduce React, Vue, Alpine, or any external state framework.
|
|
53
|
-
- Do not hard-code colors; use semantic Tailwind tokens.
|
|
54
|
-
|
|
55
|
-
## Important Files
|
|
56
|
-
|
|
57
|
-
- `.senren/skill.md` - centralized AI agent guide
|
|
58
|
-
- `.senren/registry.yml` - mirror of installable components
|
|
59
|
-
- `.senren/installed_components.yml` - what is currently installed
|
|
60
|
-
- `.senren/conventions.md` - Senren conventions for humans and agents
|
|
61
|
-
|
|
62
|
-
## Installed Components (#{names.size})
|
|
63
|
-
|
|
64
|
-
#{names.map { |n| "- #{n}" }.join("\n")}
|
|
65
|
-
TXT
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def render_full
|
|
69
|
-
comps = installed.sort_by(&:name)
|
|
70
|
-
|
|
71
|
-
out = []
|
|
72
|
-
out << '# Senren UI - Full Snapshot'
|
|
73
|
-
out << ''
|
|
74
|
-
out << 'Generated by senren-ui. Do not edit by hand. Run'
|
|
75
|
-
out << '`bin/rails senren:llms:generate` to regenerate.'
|
|
76
|
-
out << ''
|
|
77
|
-
out << '## Hard Rules'
|
|
78
|
-
out << ''
|
|
79
|
-
out << '- Use Senren components before writing custom HTML.'
|
|
80
|
-
out << '- Server-rendered HTML first; ViewComponent for reusable UI.'
|
|
81
|
-
out << '- Hotwire (Turbo + Stimulus) is the only client runtime.'
|
|
82
|
-
out << '- TailwindCSS with semantic tokens (`bg-background`, `text-foreground`, ...).'
|
|
83
|
-
out << '- Do not introduce React, Vue, Alpine, or external state frameworks.'
|
|
84
|
-
out << '- Components copied into `app/components/senren/` are owned by this app; edit them directly.'
|
|
85
|
-
out << ''
|
|
86
|
-
out << '## Installed Component Inventory'
|
|
87
|
-
out << ''
|
|
88
|
-
|
|
89
|
-
registry.groups.each do |group|
|
|
90
|
-
group_comps = comps.select { |c| c.category == group['id'] }
|
|
91
|
-
next if group_comps.empty?
|
|
92
|
-
|
|
93
|
-
out << "### #{group['title']}"
|
|
94
|
-
out << ''
|
|
95
|
-
out << group['description'].to_s
|
|
96
|
-
out << ''
|
|
97
|
-
group_comps.each { |c| out << render_component(c) }
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
out << '## Recipes'
|
|
101
|
-
out << ''
|
|
102
|
-
registry.recipes.each do |id, recipe|
|
|
103
|
-
out << "### #{id}"
|
|
104
|
-
out << ''
|
|
105
|
-
out << recipe['description'].to_s
|
|
106
|
-
out << ''
|
|
107
|
-
out << 'Components:'
|
|
108
|
-
recipe['components'].each { |c| out << "- #{c}" }
|
|
109
|
-
out << ''
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
out.join("\n")
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def render_component(comp)
|
|
116
|
-
ruby_class = "Senren::#{comp.name.split('_').map { |w| w[0].upcase + w[1..] }.join}Component"
|
|
117
|
-
s = []
|
|
118
|
-
s << "#### #{comp.name}#{' (stub)' if comp.stub?}"
|
|
119
|
-
s << ''
|
|
120
|
-
s << "Category: #{comp.category}. " \
|
|
121
|
-
"Client: #{comp.client? ? "yes (#{comp.controller})" : 'no'}. " \
|
|
122
|
-
"Variants: #{comp.variants.empty? ? 'none' : comp.variants.join(', ')}."
|
|
123
|
-
s << ''
|
|
124
|
-
s << "Use for: #{comp.use_for.join('; ')}." unless comp.use_for.empty?
|
|
125
|
-
s << "Avoid: #{comp.avoid.join('; ')}." unless comp.avoid.empty?
|
|
126
|
-
s << ''
|
|
127
|
-
s << 'Rails usage:'
|
|
128
|
-
s << ''
|
|
129
|
-
s << '```erb'
|
|
130
|
-
s << if comp.variants.any?
|
|
131
|
-
"<%= render #{ruby_class}.new(variant: :#{comp.variants.first}) do %>"
|
|
132
|
-
else
|
|
133
|
-
"<%= render #{ruby_class}.new do %>"
|
|
134
|
-
end
|
|
135
|
-
s << ' ...'
|
|
136
|
-
s << '<% end %>'
|
|
137
|
-
s << '```'
|
|
138
|
-
s << ''
|
|
139
|
-
s.join("\n")
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def atomic_write(path, content)
|
|
143
|
-
tmp = "#{path}.tmp"
|
|
144
|
-
File.write(tmp, content)
|
|
145
|
-
File.rename(tmp, path)
|
|
18
|
+
AgentRulesWriter.new(registry: registry, paths: paths).sync!
|
|
146
19
|
end
|
|
147
20
|
end
|
|
148
21
|
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,7 +8,9 @@ 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'
|
|
13
|
+
autoload :AgentRulesWriter, 'senren/rails/agent_rules_writer'
|
|
12
14
|
autoload :LlmsWriter, 'senren/rails/llms_writer'
|
|
13
15
|
autoload :Installer, 'senren/rails/installer'
|
|
14
16
|
autoload :Doctor, 'senren/rails/doctor'
|
data/lib/tasks/senren.rake
CHANGED
|
@@ -3,40 +3,49 @@
|
|
|
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::LlmsWriter.new(registry: registry, paths: paths).generate!
|
|
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
|
|
25
|
-
desc 'Rebuild .senren/skill.md
|
|
22
|
+
desc 'Rebuild .senren/skill.md and refresh agent instruction adapters.'
|
|
26
23
|
task sync: :environment do
|
|
27
24
|
paths = Senren::Rails::HostPaths.new
|
|
28
25
|
registry = Senren::Rails::Registry.load!
|
|
29
26
|
file = Senren::Rails::SkillWriter.new(registry: registry, paths: paths).sync!
|
|
30
27
|
puts "Wrote #{file}"
|
|
28
|
+
Senren::Rails::AgentRulesWriter.new(registry: registry, paths: paths).sync!
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
namespace :agents do
|
|
33
|
+
desc 'Regenerate .senren/agent-rules.md and adapter instruction files.'
|
|
34
|
+
task sync: :environment do
|
|
35
|
+
paths = Senren::Rails::HostPaths.new
|
|
36
|
+
registry = Senren::Rails::Registry.load!
|
|
37
|
+
files = Senren::Rails::AgentRulesWriter.new(registry: registry, paths: paths).sync!
|
|
38
|
+
files.each { |f| puts "Wrote #{f}" }
|
|
31
39
|
end
|
|
32
40
|
end
|
|
33
41
|
|
|
34
42
|
namespace :llms do
|
|
35
|
-
desc '
|
|
43
|
+
desc 'Deprecated alias for senren:agents:sync.'
|
|
36
44
|
task generate: :environment do
|
|
45
|
+
puts 'senren:llms:generate is deprecated. Running senren:agents:sync instead.'
|
|
37
46
|
paths = Senren::Rails::HostPaths.new
|
|
38
47
|
registry = Senren::Rails::Registry.load!
|
|
39
|
-
files = Senren::Rails::
|
|
48
|
+
files = Senren::Rails::AgentRulesWriter.new(registry: registry, paths: paths).sync!
|
|
40
49
|
files.each { |f| puts "Wrote #{f}" }
|
|
41
50
|
end
|
|
42
51
|
end
|
|
@@ -55,11 +64,7 @@ def parse_names(args)
|
|
|
55
64
|
raw.concat(args.extras)
|
|
56
65
|
raw << args[:names] if args[:names]
|
|
57
66
|
raw.concat(ARGV.drop_while { |a| !a.start_with?('senren:') }.drop(1))
|
|
58
|
-
raw
|
|
59
|
-
.flatten
|
|
60
|
-
.flat_map { |s| s.to_s.split(/[,\s]+/) }
|
|
61
|
-
.reject { |s| s.empty? || s.start_with?('-') }
|
|
62
|
-
.uniq
|
|
67
|
+
Senren::Rails::ComponentInstaller.normalize_names(raw)
|
|
63
68
|
end
|
|
64
69
|
|
|
65
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 %>
|