senren-ui 0.1.6 → 0.2.0
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 +104 -0
- data/CONTRIBUTING.md +40 -3
- data/README.md +110 -33
- data/Rakefile +14 -1
- data/docs/components.md +10 -10
- data/docs/hot_reload.md +103 -0
- data/docs/performance_testing.md +7 -3
- data/lib/generators/senren/component/templates/controller.js.tt +7 -4
- data/lib/generators/senren/install/install_generator.rb +74 -0
- data/lib/generators/senren/install/templates/base_component.rb.tt +52 -4
- data/lib/generators/senren/install/templates/conventions.md.tt +16 -1
- data/lib/senren/rails/agent_rules_writer.rb +62 -19
- data/lib/senren/rails/asset_path_guard.rb +128 -0
- data/lib/senren/rails/base_component_patch.rb +64 -0
- data/lib/senren/rails/component_copier.rb +101 -50
- data/lib/senren/rails/component_installer.rb +26 -0
- data/lib/senren/rails/doctor.rb +7 -4
- data/lib/senren/rails/engine.rb +23 -0
- data/lib/senren/rails/host_paths.rb +11 -2
- data/lib/senren/rails/marker_block.rb +81 -0
- data/lib/senren/rails/registry.rb +10 -4
- data/lib/senren/rails/safe_write.rb +169 -0
- data/lib/senren/rails/skill_writer.rb +47 -11
- data/lib/senren/rails/version.rb +1 -1
- data/lib/senren/rails.rb +1 -1
- data/lib/senren-ui.rb +15 -0
- data/lib/tasks/senren.rake +43 -17
- data/registry/components.yml +45 -0
- data/registry/recipes.yml +12 -0
- data/templates/components/alert_dialog/alert_dialog_component.rb +1 -1
- data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
- data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
- data/templates/components/avatar/avatar_component.rb +8 -1
- data/templates/components/cart/cart_component.html.erb +61 -0
- data/templates/components/cart/cart_component.rb +71 -0
- data/templates/components/checkbox/checkbox_component.rb +1 -1
- data/templates/components/clipboard/clipboard_component.html.erb +1 -1
- data/templates/components/command/command_component.rb +1 -1
- data/templates/components/date_picker/date_picker_component.html.erb +1 -1
- data/templates/components/dialog/dialog_component.rb +1 -1
- data/templates/components/form/form_component.rb +9 -1
- data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
- data/templates/components/product_card/product_card_component.html.erb +38 -0
- data/templates/components/product_card/product_card_component.rb +49 -0
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
- data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
- data/templates/components/separator/separator_component.rb +7 -0
- data/templates/components/sheet/sheet_component.rb +1 -1
- data/templates/components/tooltip/tooltip_component.rb +2 -2
- data/templates/components/typography/typography_component.rb +7 -0
- data/templates/controllers/accordion_controller.js +1 -1
- data/templates/controllers/alert_dialog_controller.js +31 -7
- data/templates/controllers/cart_controller.js +83 -0
- data/templates/controllers/clipboard_controller.js +12 -1
- data/templates/controllers/command_controller.js +3 -4
- data/templates/controllers/context_menu_controller.js +38 -11
- data/templates/controllers/data_table_controller.js +8 -3
- data/templates/controllers/dialog_controller.js +47 -21
- data/templates/controllers/dropdown_menu_controller.js +40 -27
- data/templates/controllers/hover_card_controller.js +8 -0
- data/templates/controllers/invite_member_dialog_controller.js +6 -0
- data/templates/controllers/masked_input_controller.js +8 -1
- data/templates/controllers/popover_controller.js +25 -10
- data/templates/controllers/rich_text_editor_lite_controller.js +165 -28
- data/templates/controllers/sheet_controller.js +41 -11
- metadata +13 -17
- data/lib/senren/rails/installer.rb +0 -85
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'yaml'
|
|
4
|
+
require 'senren/rails/marker_block'
|
|
5
|
+
require 'senren/rails/safe_write'
|
|
4
6
|
|
|
5
7
|
module Senren
|
|
6
8
|
module Rails
|
|
@@ -30,16 +32,23 @@ module Senren
|
|
|
30
32
|
installed = installed_names
|
|
31
33
|
body = render(installed)
|
|
32
34
|
|
|
33
|
-
paths.senren_dir.
|
|
35
|
+
SafeWrite.mkdir_p!(paths.senren_dir, paths.root, 'skill.md')
|
|
34
36
|
existing = paths.skill_file.exist? ? paths.skill_file.read : default_outer_template
|
|
35
37
|
|
|
36
38
|
new_content = inject(existing, body)
|
|
37
|
-
paths.skill_file
|
|
39
|
+
atomic_write(paths.skill_file, new_content)
|
|
38
40
|
paths.skill_file
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
private
|
|
42
44
|
|
|
45
|
+
# Routed through SafeWrite rather than hand-rolled. assert_inside! plus
|
|
46
|
+
# rename was already safe for an existing symlink, but not for a dangling
|
|
47
|
+
# one, and two other writers proved that hand-rolled writes drift.
|
|
48
|
+
def atomic_write(path, content)
|
|
49
|
+
SafeWrite.write!(path, content, paths.root, path.to_s)
|
|
50
|
+
end
|
|
51
|
+
|
|
43
52
|
def installed_names
|
|
44
53
|
path = paths.installed_components
|
|
45
54
|
return [] unless path.exist?
|
|
@@ -110,21 +119,51 @@ module Senren
|
|
|
110
119
|
end
|
|
111
120
|
end
|
|
112
121
|
|
|
122
|
+
# Describes what is installed, not what the registry offers.
|
|
123
|
+
#
|
|
124
|
+
# This rendered the registry default, so after `senren:add select
|
|
125
|
+
# --no-client` the skill file told agents to use
|
|
126
|
+
# `senren--select` and named a controller file that is not on disk. The
|
|
127
|
+
# copier computes the truth and writes it to the ledger — with a comment
|
|
128
|
+
# saying "never record client behavior in the ledger that was not
|
|
129
|
+
# installed" — and nothing read it back. The gem's flagship feature was
|
|
130
|
+
# wrong for precisely the installs where the flag was exercised.
|
|
113
131
|
def client_summary(comp)
|
|
114
132
|
return 'none' unless comp.client?
|
|
133
|
+
return 'none (installed without its Stimulus controller)' unless installed_with_client?(comp.name)
|
|
115
134
|
|
|
116
135
|
path = "app/javascript/controllers/senren/#{comp.name}_controller.js"
|
|
117
136
|
"Stimulus `#{comp.controller}` (`#{path}`)"
|
|
118
137
|
end
|
|
119
138
|
|
|
139
|
+
# nil when the ledger predates this field, in which case the registry
|
|
140
|
+
# default is the best available answer.
|
|
141
|
+
def installed_with_client?(name)
|
|
142
|
+
entry = ledger_entries.find { |e| e['name'] == name }
|
|
143
|
+
return true if entry.nil? || !entry.key?('client')
|
|
144
|
+
|
|
145
|
+
entry['client']
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def ledger_entries
|
|
149
|
+
@ledger_entries ||= begin
|
|
150
|
+
path = paths.installed_components
|
|
151
|
+
path.exist? ? Array((YAML.safe_load_file(path) || {})['installed']) : []
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
120
155
|
def format_list(items)
|
|
121
156
|
return '_none_' if items.empty?
|
|
122
157
|
|
|
123
158
|
items.map { |i| "`#{i}`" }.join(', ')
|
|
124
159
|
end
|
|
125
160
|
|
|
161
|
+
# NAME_PATTERN accepts consecutive underscores, and "foo__bar".split("_")
|
|
162
|
+
# yields an empty segment, so w[0] was nil and this raised NoMethodError —
|
|
163
|
+
# after the files were copied and the ledger written, leaving a
|
|
164
|
+
# half-completed install. Empty segments are dropped instead.
|
|
126
165
|
def humanize(name)
|
|
127
|
-
name.split('_').map { |w| w[0].upcase + w[1..] }.join
|
|
166
|
+
name.split('_').reject(&:empty?).map { |w| w[0].upcase + w[1..] }.join
|
|
128
167
|
end
|
|
129
168
|
|
|
130
169
|
def ruby_class_for(name)
|
|
@@ -132,13 +171,10 @@ module Senren
|
|
|
132
171
|
end
|
|
133
172
|
|
|
134
173
|
def inject(existing, generated_body)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
else
|
|
140
|
-
"#{existing.rstrip}\n\n#{START_MARKER}\n\n#{generated_body}\n\n#{END_MARKER}\n"
|
|
141
|
-
end
|
|
174
|
+
MarkerBlock.inject(
|
|
175
|
+
existing, generated_body,
|
|
176
|
+
start_marker: START_MARKER, end_marker: END_MARKER, label: paths.skill_file.to_s
|
|
177
|
+
)
|
|
142
178
|
end
|
|
143
179
|
|
|
144
180
|
def default_outer_template
|
|
@@ -151,7 +187,7 @@ module Senren
|
|
|
151
187
|
|
|
152
188
|
- Use Senren components before writing custom HTML.
|
|
153
189
|
- Use ViewComponent for reusable UI; Turbo for server state; Stimulus for local behavior only.
|
|
154
|
-
-
|
|
190
|
+
- Keep interactivity in Stimulus rather than a client-side framework.
|
|
155
191
|
- Do not hard-code colors; use semantic Tailwind tokens like `bg-background`, `text-foreground`, `bg-primary`.
|
|
156
192
|
|
|
157
193
|
## Installed Components
|
data/lib/senren/rails/version.rb
CHANGED
data/lib/senren/rails.rb
CHANGED
|
@@ -12,9 +12,9 @@ module Senren
|
|
|
12
12
|
autoload :SkillWriter, 'senren/rails/skill_writer'
|
|
13
13
|
autoload :AgentRulesWriter, 'senren/rails/agent_rules_writer'
|
|
14
14
|
autoload :LlmsWriter, 'senren/rails/llms_writer'
|
|
15
|
-
autoload :Installer, 'senren/rails/installer'
|
|
16
15
|
autoload :Doctor, 'senren/rails/doctor'
|
|
17
16
|
autoload :HostPaths, 'senren/rails/host_paths'
|
|
17
|
+
autoload :MarkerBlock, 'senren/rails/marker_block'
|
|
18
18
|
|
|
19
19
|
def self.gem_root
|
|
20
20
|
GEM_ROOT
|
data/lib/senren-ui.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The entry point Bundler reaches for by default.
|
|
4
|
+
#
|
|
5
|
+
# `gem "senren-ui"` with no `require:` option makes Bundler try "senren-ui",
|
|
6
|
+
# then its hyphen-to-slash fallback "senren/ui". Without this file both fail —
|
|
7
|
+
# and Bundler swallows the second LoadError, so the app boots with no engine,
|
|
8
|
+
# no rake tasks, and no AssetPathGuard, while the generator and `senren:add`
|
|
9
|
+
# keep working because they require "senren/rails" themselves.
|
|
10
|
+
#
|
|
11
|
+
# That combination is the worst possible failure mode: the install looks
|
|
12
|
+
# healthy and the production source-disclosure guard is simply absent. The
|
|
13
|
+
# README documents `require: "senren/rails"`, which is still correct; this makes
|
|
14
|
+
# forgetting it harmless rather than silent.
|
|
15
|
+
require 'senren/rails'
|
data/lib/tasks/senren.rake
CHANGED
|
@@ -6,8 +6,8 @@ namespace :senren do
|
|
|
6
6
|
desc 'Install one or more Senren components. Preferred: bin/rails senren:add button dialog. ' \
|
|
7
7
|
"Legacy: bin/rails 'senren:add[button,dialog]'"
|
|
8
8
|
task :add, [:names] do |_t, args|
|
|
9
|
-
names =
|
|
10
|
-
options =
|
|
9
|
+
names = SenrenRakeArgs.names(args)
|
|
10
|
+
options = SenrenRakeArgs.options
|
|
11
11
|
|
|
12
12
|
Senren::Rails::ComponentInstaller.new.install(
|
|
13
13
|
names: names,
|
|
@@ -59,21 +59,47 @@ end
|
|
|
59
59
|
|
|
60
60
|
# Helpers ---------------------------------------------------------------
|
|
61
61
|
|
|
62
|
-
def
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
end
|
|
62
|
+
# Namespaced deliberately. These were top-level `def`s, which Ruby defines as
|
|
63
|
+
# private methods on Object — so loading this rake file gave every class in the
|
|
64
|
+
# host app a `parse_options` and a `parse_names`. `parse_options` is a
|
|
65
|
+
# plausible name for an app to define itself.
|
|
66
|
+
module SenrenRakeArgs
|
|
67
|
+
module_function
|
|
69
68
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
69
|
+
def names(args)
|
|
70
|
+
raw = []
|
|
71
|
+
raw.concat(args.extras)
|
|
72
|
+
raw << args[:names] if args[:names]
|
|
73
|
+
raw.concat(trailing_arguments)
|
|
74
|
+
Senren::Rails::ComponentInstaller.normalize_names(raw)
|
|
75
|
+
end
|
|
77
76
|
|
|
78
|
-
|
|
77
|
+
def options
|
|
78
|
+
words = trailing_arguments
|
|
79
|
+
client_override = true if words.include?('--client')
|
|
80
|
+
client_override = false if words.include?('--no-client')
|
|
81
|
+
|
|
82
|
+
{ client_override: client_override, force: words.include?('--force') }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The words that belong to `senren:add`, and only those.
|
|
86
|
+
#
|
|
87
|
+
# This used to be `ARGV.drop_while { ... }.drop(1)` with no upper bound, so
|
|
88
|
+
# every later argument on the command line was swallowed: `rake
|
|
89
|
+
# 'senren:add[button]' db:seed` tried to install a component named "db:seed"
|
|
90
|
+
# and aborted the whole invocation, and a `--force` intended for a different
|
|
91
|
+
# task silently enabled overwriting here.
|
|
92
|
+
def trailing_arguments
|
|
93
|
+
ARGV
|
|
94
|
+
.drop_while { |arg| !arg.start_with?('senren:') }
|
|
95
|
+
.drop(1)
|
|
96
|
+
.take_while { |arg| !rake_target?(arg) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A namespaced task (`db:seed`) or an environment assignment (`RAILS_ENV=x`).
|
|
100
|
+
# Component names are matched by Registry::NAME_PATTERN and can contain
|
|
101
|
+
# neither character.
|
|
102
|
+
def rake_target?(arg)
|
|
103
|
+
arg.include?(':') || arg.include?('=')
|
|
104
|
+
end
|
|
79
105
|
end
|
data/registry/components.yml
CHANGED
|
@@ -1051,3 +1051,48 @@ components:
|
|
|
1051
1051
|
ai:
|
|
1052
1052
|
use_for: [light formatting (bold/italic/links/lists) only]
|
|
1053
1053
|
avoid: [tables, embeds, media - out of scope for v0.1]
|
|
1054
|
+
|
|
1055
|
+
product_card:
|
|
1056
|
+
category: saas
|
|
1057
|
+
client: false
|
|
1058
|
+
can_have_client: false
|
|
1059
|
+
files:
|
|
1060
|
+
- app/components/senren/product_card_component.rb
|
|
1061
|
+
- app/components/senren/product_card_component.html.erb
|
|
1062
|
+
depends_on: []
|
|
1063
|
+
pairs_with: [cart, badge, button, card]
|
|
1064
|
+
variants: [default, featured]
|
|
1065
|
+
accessibility:
|
|
1066
|
+
- Image alt text repeats the product title; decorative placeholder is aria-hidden.
|
|
1067
|
+
- Out of stock disables the submit button rather than hiding it.
|
|
1068
|
+
ai:
|
|
1069
|
+
use_for:
|
|
1070
|
+
- product listings
|
|
1071
|
+
- catalogue grids
|
|
1072
|
+
- storefront search results
|
|
1073
|
+
avoid:
|
|
1074
|
+
- carrying cart state; adding to a cart is a form submission the server owns
|
|
1075
|
+
|
|
1076
|
+
cart:
|
|
1077
|
+
category: saas
|
|
1078
|
+
client: true
|
|
1079
|
+
can_have_client: true
|
|
1080
|
+
controller: senren--cart
|
|
1081
|
+
files:
|
|
1082
|
+
- app/components/senren/cart_component.rb
|
|
1083
|
+
- app/components/senren/cart_component.html.erb
|
|
1084
|
+
- app/javascript/controllers/senren/cart_controller.js
|
|
1085
|
+
depends_on: []
|
|
1086
|
+
pairs_with: [product_card, button, separator, empty_state]
|
|
1087
|
+
variants: [default, flush]
|
|
1088
|
+
accessibility:
|
|
1089
|
+
- Quantity steppers are grouped with an accessible name per line.
|
|
1090
|
+
- Subtotal is text, not an image, so it is announced on change.
|
|
1091
|
+
ai:
|
|
1092
|
+
use_for:
|
|
1093
|
+
- cart summary panels
|
|
1094
|
+
- checkout side panels
|
|
1095
|
+
- order review before payment
|
|
1096
|
+
avoid:
|
|
1097
|
+
- persisting quantities; the controller is display-only and the server is authoritative
|
|
1098
|
+
- formatting currency in the component; pass price_cents and a formatted price
|
data/registry/recipes.yml
CHANGED
|
@@ -77,3 +77,15 @@ recipes:
|
|
|
77
77
|
- native_select
|
|
78
78
|
- button
|
|
79
79
|
- alert
|
|
80
|
+
|
|
81
|
+
storefront:
|
|
82
|
+
description: Product listing with a cart panel; the server owns the cart, the widget owns the UI.
|
|
83
|
+
components:
|
|
84
|
+
- app_shell
|
|
85
|
+
- top_nav
|
|
86
|
+
- page_header
|
|
87
|
+
- product_card
|
|
88
|
+
- cart
|
|
89
|
+
- badge
|
|
90
|
+
- button
|
|
91
|
+
- empty_state
|
|
@@ -13,7 +13,7 @@ module Senren
|
|
|
13
13
|
|
|
14
14
|
def initialize(variant: :default, id: nil, class_name: nil, **html)
|
|
15
15
|
super(variant: variant, size: :md, class_name: class_name, **html)
|
|
16
|
-
@dom_id = id ||
|
|
16
|
+
@dom_id = id || senren_dom_id
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
attr_reader :dom_id
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<% else %>
|
|
5
5
|
<label class="block text-sm font-medium text-[hsl(var(--senren-foreground))]"><%= label %></label>
|
|
6
6
|
<div class="flex flex-col gap-2 sm:flex-row">
|
|
7
|
-
<input type="password" readonly value="<%= value %>" data-senren--api-key-field-target="input" class="h-10 min-w-0 flex-1 rounded-(--senren-radius) border border-[hsl(var(--senren-input))] bg-[hsl(var(--senren-muted)/0.35)] px-3 font-mono text-sm text-[hsl(var(--senren-foreground))] outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
|
|
7
|
+
<input type="password" readonly aria-label="<%= label %>" value="<%= value %>" data-senren--api-key-field-target="input" class="h-10 min-w-0 flex-1 rounded-(--senren-radius) border border-[hsl(var(--senren-input))] bg-[hsl(var(--senren-muted)/0.35)] px-3 font-mono text-sm text-[hsl(var(--senren-foreground))] outline-none focus:ring-2 focus:ring-[hsl(var(--senren-ring))]">
|
|
8
8
|
<button type="button" data-senren--api-key-field-target="revealButton" data-action="click->senren--api-key-field#toggle" class="inline-flex h-10 cursor-pointer items-center justify-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] px-3 text-sm font-medium hover:bg-[hsl(var(--senren-accent))]"><%= reveal_label %></button>
|
|
9
9
|
<button type="button" data-action="click->senren--api-key-field#copy" class="inline-flex h-10 cursor-pointer items-center justify-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-3 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90"><%= copy_label %></button>
|
|
10
10
|
</div>
|
|
@@ -10,5 +10,12 @@ module Senren
|
|
|
10
10
|
}.freeze
|
|
11
11
|
|
|
12
12
|
SIZES = { md: '' }.freeze
|
|
13
|
+
|
|
14
|
+
# BaseComponent defaults to `variant: :default`, which this component does
|
|
15
|
+
# not define, so `.new` with no variant raised instead of rendering. The
|
|
16
|
+
# default is :square (the neutral ratio).
|
|
17
|
+
def initialize(variant: :square, **args)
|
|
18
|
+
super
|
|
19
|
+
end
|
|
13
20
|
end
|
|
14
21
|
end
|
|
@@ -17,7 +17,14 @@ module Senren
|
|
|
17
17
|
@fallback = fallback.presence || initials.presence || initials_from_alt
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
attr_reader :
|
|
20
|
+
attr_reader :alt, :fallback
|
|
21
|
+
|
|
22
|
+
# Avatar sources are user-controlled by nature — a profile URL is the
|
|
23
|
+
# canonical example — and this one reaches image_tag. Rails' asset_path
|
|
24
|
+
# incidentally neutralises `javascript:`, so this is not XSS, but an
|
|
25
|
+
# off-origin `//tracker.example/x.gif` is a beacon that fires for every
|
|
26
|
+
# viewer of the page. safe_media_url is the media-specific policy.
|
|
27
|
+
def src = @src && safe_media_url(@src)
|
|
21
28
|
|
|
22
29
|
private
|
|
23
30
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<%= tag.section(**root_attrs("flex flex-col rounded-(--senren-radius) border bg-[hsl(var(--senren-card))] shadow-sm", id: dom_id, "aria-label": title, data: { controller: "senren--cart", "senren--cart-currency-value": currency, "senren--cart-subtotal-cents-value": subtotal_cents })) do %>
|
|
2
|
+
<header class="flex items-center justify-between border-b border-[hsl(var(--senren-border))] px-4 py-3">
|
|
3
|
+
<h2 class="text-sm font-semibold text-[hsl(var(--senren-foreground))]"><%= title %></h2>
|
|
4
|
+
<span class="text-sm text-[hsl(var(--senren-muted-foreground))]" data-senren--cart-target="count"><%= total_quantity %></span>
|
|
5
|
+
</header>
|
|
6
|
+
|
|
7
|
+
<% if empty? %>
|
|
8
|
+
<p class="px-4 py-8 text-center text-sm text-[hsl(var(--senren-muted-foreground))]"><%= empty_text %></p>
|
|
9
|
+
<% else %>
|
|
10
|
+
<ul class="divide-y divide-[hsl(var(--senren-border))]">
|
|
11
|
+
<% items.each do |item| %>
|
|
12
|
+
<li class="flex items-center gap-3 px-4 py-3"
|
|
13
|
+
data-senren--cart-target="line"
|
|
14
|
+
data-line-id="<%= item[:id] %>"
|
|
15
|
+
data-price-cents="<%= item[:price_cents] %>">
|
|
16
|
+
<% if (image = safe_media_url(item[:image_url])) %>
|
|
17
|
+
<img src="<%= image %>" alt="" loading="lazy" class="h-12 w-12 shrink-0 rounded-(--senren-radius) object-cover">
|
|
18
|
+
<% else %>
|
|
19
|
+
<div class="h-12 w-12 shrink-0 rounded-(--senren-radius) bg-[hsl(var(--senren-muted)/0.5)]" aria-hidden="true"></div>
|
|
20
|
+
<% end %>
|
|
21
|
+
|
|
22
|
+
<div class="min-w-0 flex-1">
|
|
23
|
+
<p class="truncate text-sm font-medium text-[hsl(var(--senren-foreground))]"><%= item[:name] %></p>
|
|
24
|
+
<p class="text-sm text-[hsl(var(--senren-muted-foreground))]" data-senren--cart-target="lineTotal"><%= line_total(item) %></p>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div class="flex items-center gap-1" role="group" aria-label="<%= item[:name] %> quantity">
|
|
28
|
+
<button type="button"
|
|
29
|
+
data-action="click->senren--cart#decrement"
|
|
30
|
+
aria-label="Decrease <%= item[:name] %> quantity"
|
|
31
|
+
class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] text-sm hover:bg-[hsl(var(--senren-accent))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]">−</button>
|
|
32
|
+
|
|
33
|
+
<output class="w-8 text-center text-sm tabular-nums text-[hsl(var(--senren-foreground))]"
|
|
34
|
+
data-senren--cart-target="quantity"><%= item[:quantity] %></output>
|
|
35
|
+
|
|
36
|
+
<button type="button"
|
|
37
|
+
data-action="click->senren--cart#increment"
|
|
38
|
+
aria-label="Increase <%= item[:name] %> quantity"
|
|
39
|
+
class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] text-sm hover:bg-[hsl(var(--senren-accent))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]">+</button>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<button type="button"
|
|
43
|
+
data-action="click->senren--cart#remove"
|
|
44
|
+
aria-label="Remove <%= item[:name] %>"
|
|
45
|
+
class="inline-flex h-8 cursor-pointer items-center rounded-(--senren-radius) px-2 text-sm text-[hsl(var(--senren-muted-foreground))] hover:bg-[hsl(var(--senren-accent))] hover:text-[hsl(var(--senren-destructive))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]">Remove</button>
|
|
46
|
+
</li>
|
|
47
|
+
<% end %>
|
|
48
|
+
</ul>
|
|
49
|
+
|
|
50
|
+
<footer class="flex items-center justify-between gap-3 border-t border-[hsl(var(--senren-border))] px-4 py-3">
|
|
51
|
+
<div class="text-sm text-[hsl(var(--senren-muted-foreground))]">
|
|
52
|
+
Subtotal
|
|
53
|
+
<span class="ml-1 font-semibold text-[hsl(var(--senren-foreground))]" data-senren--cart-target="subtotal"><%= subtotal %></span>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<% if safe_checkout_url %>
|
|
57
|
+
<a href="<%= safe_checkout_url %>" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-4 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]"><%= checkout_label %></a>
|
|
58
|
+
<% end %>
|
|
59
|
+
</footer>
|
|
60
|
+
<% end %>
|
|
61
|
+
<% end %>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Senren
|
|
4
|
+
# A shopping cart: line items with quantity steppers, a live subtotal, and
|
|
5
|
+
# removal.
|
|
6
|
+
#
|
|
7
|
+
# The server owns the cart. This component owns the widget. The Stimulus
|
|
8
|
+
# controller only does what does not round-trip pleasantly — stepping a
|
|
9
|
+
# quantity and recomputing the displayed subtotal while the user clicks — and
|
|
10
|
+
# announces the result with senren--cart:changed so a host app can update a
|
|
11
|
+
# header badge without reaching inside.
|
|
12
|
+
#
|
|
13
|
+
# Money arrives twice, and on purpose: `price` already formatted for display,
|
|
14
|
+
# and `price_cents` as an integer for arithmetic. Formatting is locale- and
|
|
15
|
+
# money-library-specific and belongs to the host app. Doing arithmetic on a
|
|
16
|
+
# formatted string is how currency bugs start.
|
|
17
|
+
class CartComponent < BaseComponent
|
|
18
|
+
VARIANTS = {
|
|
19
|
+
default: 'border-[hsl(var(--senren-border))]',
|
|
20
|
+
flush: 'border-transparent shadow-none'
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
SIZES = { md: '' }.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(items: [], currency: '$', title: 'Cart', empty_text: 'Your cart is empty.',
|
|
26
|
+
checkout_url: nil, checkout_label: 'Checkout', variant: :default,
|
|
27
|
+
id: nil, class_name: nil, **html)
|
|
28
|
+
super(variant: variant, size: :md, class_name: class_name, **html)
|
|
29
|
+
@items = Array(items).map { |item| normalize_item(item) }
|
|
30
|
+
@currency = currency
|
|
31
|
+
@title = title
|
|
32
|
+
@empty_text = empty_text
|
|
33
|
+
@checkout_url = checkout_url
|
|
34
|
+
@checkout_label = checkout_label
|
|
35
|
+
@dom_id = id || senren_dom_id(title)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
attr_reader :items, :currency, :title, :empty_text, :checkout_label, :dom_id
|
|
39
|
+
|
|
40
|
+
def empty? = items.empty?
|
|
41
|
+
def total_quantity = items.sum { |item| item[:quantity] }
|
|
42
|
+
def subtotal_cents = items.sum { |item| item[:price_cents] * item[:quantity] }
|
|
43
|
+
def subtotal = format_cents(subtotal_cents)
|
|
44
|
+
def line_total(item) = format_cents(item[:price_cents] * item[:quantity])
|
|
45
|
+
def safe_checkout_url = @checkout_url && safe_url(@checkout_url)
|
|
46
|
+
|
|
47
|
+
# Kept public so the same rendering is available to a host app updating a
|
|
48
|
+
# line through a Turbo Stream.
|
|
49
|
+
# Kernel.format explicitly: ViewComponent::Base defines its own `format`
|
|
50
|
+
# (the current template format, taking no arguments), which shadows
|
|
51
|
+
# Kernel#format inside a component. On Rails 8.x this happened to resolve;
|
|
52
|
+
# on 7.1 it raised "wrong number of arguments (given 2, expected 0)" as soon
|
|
53
|
+
# as the call ran inside a tag block. Caught by the version matrix.
|
|
54
|
+
def format_cents(cents)
|
|
55
|
+
"#{currency}#{Kernel.format('%.2f', cents.to_i / 100.0)}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def normalize_item(item)
|
|
61
|
+
{
|
|
62
|
+
id: item[:id] || item['id'],
|
|
63
|
+
name: item[:name] || item['name'],
|
|
64
|
+
price_cents: (item[:price_cents] || item['price_cents']).to_i,
|
|
65
|
+
quantity: [(item[:quantity] || item['quantity'] || 1).to_i, 1].max,
|
|
66
|
+
image_url: item[:image_url] || item['image_url'],
|
|
67
|
+
remove_url: item[:remove_url] || item['remove_url']
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<div <%= tag.attributes(**root_attrs("inline-flex items-center gap-2", data: { controller: "senren--clipboard", "senren--clipboard-copied-label-value": copied_label })) %>>
|
|
2
|
-
<input type="text" readonly value="<%= value %>" data-senren--clipboard-target="source" class="h-10 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-muted)/0.4)] px-3 font-mono text-sm text-[hsl(var(--senren-foreground))]">
|
|
2
|
+
<input type="text" readonly aria-label="<%= label %>" value="<%= value %>" data-senren--clipboard-target="source" class="h-10 rounded-(--senren-radius) border border-[hsl(var(--senren-border))] bg-[hsl(var(--senren-muted)/0.4)] px-3 font-mono text-sm text-[hsl(var(--senren-foreground))]">
|
|
3
3
|
<button type="button" data-senren--clipboard-target="button" data-action="click->senren--clipboard#copy" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-3 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90">
|
|
4
4
|
<%= label %>
|
|
5
5
|
</button>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<div <%= tag.attributes(**root_attrs("flex w-full items-center gap-2", data: { controller: "senren--date-picker" })) %>>
|
|
2
|
-
<input type="date" id="<%= id %>" name="<%= name %>" value="<%= value %>" placeholder="<%= placeholder %>" data-senren--date-picker-target="input" class="
|
|
2
|
+
<input type="date" id="<%= id %>" name="<%= name %>" value="<%= value %>" placeholder="<%= placeholder %>" data-senren--date-picker-target="input" class="h-10 w-full max-w-56 cursor-pointer rounded-(--senren-radius) border bg-[hsl(var(--senren-background))] px-3 text-sm text-[hsl(var(--senren-foreground))] outline-none transition-colors focus:ring-2 focus:ring-[hsl(var(--senren-ring))] [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-60 [&::-webkit-calendar-picker-indicator]:hover:opacity-100 <%= self.class::VARIANTS[variant] %>">
|
|
3
3
|
<button type="button" data-action="click->senren--date-picker#today" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] px-3 text-sm font-medium hover:bg-[hsl(var(--senren-accent))]">Today</button>
|
|
4
4
|
<button type="button" data-action="click->senren--date-picker#clear" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) px-3 text-sm text-[hsl(var(--senren-muted-foreground))] hover:bg-[hsl(var(--senren-accent))]">Clear</button>
|
|
5
5
|
</div>
|
|
@@ -14,7 +14,7 @@ module Senren
|
|
|
14
14
|
def initialize(open: false, id: nil, class_name: nil, **html)
|
|
15
15
|
super(variant: :default, size: :md, class_name: class_name, **html)
|
|
16
16
|
@open = open
|
|
17
|
-
@dom_id = id ||
|
|
17
|
+
@dom_id = id || senren_dom_id
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
attr_reader :open, :dom_id
|
|
@@ -15,6 +15,14 @@ module Senren
|
|
|
15
15
|
@multipart = multipart
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
attr_reader :model, :
|
|
18
|
+
attr_reader :model, :method, :multipart
|
|
19
|
+
|
|
20
|
+
# This one reaches form_with's `action`, which makes it the highest-value
|
|
21
|
+
# URL sink in the library: `//evil.example` is a protocol-relative absolute
|
|
22
|
+
# URL, so a form built from user-controlled input POSTs every field —
|
|
23
|
+
# including the CSRF token — off-origin. Same policy as every other href in
|
|
24
|
+
# the library; it was simply missed because the security test enumerated
|
|
25
|
+
# known components rather than asserting a property over all of them.
|
|
26
|
+
def url = @url && safe_url(@url)
|
|
19
27
|
end
|
|
20
28
|
end
|
|
@@ -18,7 +18,7 @@ module Senren
|
|
|
18
18
|
@role_name = role_name
|
|
19
19
|
@roles = Array(roles)
|
|
20
20
|
@button_label = button_label
|
|
21
|
-
@dom_id = id ||
|
|
21
|
+
@dom_id = id || senren_dom_id(title)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
attr_reader :title, :description, :email_name, :role_name, :roles, :button_label, :dom_id
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<%= tag.article(**root_attrs("flex flex-col overflow-hidden rounded-(--senren-radius) border bg-[hsl(var(--senren-card))] shadow-sm", id: dom_id)) do %>
|
|
2
|
+
<div class="relative aspect-square w-full bg-[hsl(var(--senren-muted)/0.4)]">
|
|
3
|
+
<% if safe_image_url %>
|
|
4
|
+
<img src="<%= safe_image_url %>" alt="<%= title %>" loading="lazy" class="h-full w-full object-cover">
|
|
5
|
+
<% else %>
|
|
6
|
+
<div class="flex h-full w-full items-center justify-center text-sm text-[hsl(var(--senren-muted-foreground))]" aria-hidden="true">
|
|
7
|
+
No image
|
|
8
|
+
</div>
|
|
9
|
+
<% end %>
|
|
10
|
+
|
|
11
|
+
<% if badge %>
|
|
12
|
+
<span class="absolute left-2 top-2 inline-flex items-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-2 py-0.5 text-xs font-medium text-[hsl(var(--senren-primary-foreground))]">
|
|
13
|
+
<%= badge %>
|
|
14
|
+
</span>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div class="flex flex-1 flex-col gap-2 p-4">
|
|
19
|
+
<h3 class="text-sm font-medium leading-tight text-[hsl(var(--senren-foreground))]">
|
|
20
|
+
<%= title %>
|
|
21
|
+
</h3>
|
|
22
|
+
|
|
23
|
+
<% if description %>
|
|
24
|
+
<p class="line-clamp-2 text-sm text-[hsl(var(--senren-muted-foreground))]"><%= description %></p>
|
|
25
|
+
<% end %>
|
|
26
|
+
|
|
27
|
+
<p class="mt-auto text-base font-semibold text-[hsl(var(--senren-foreground))]"><%= price %></p>
|
|
28
|
+
|
|
29
|
+
<%# A plain form: the server owns the cart, and Turbo handles the response. %>
|
|
30
|
+
<%= form_with url: safe_action_url, method: method, class: "mt-1" do %>
|
|
31
|
+
<button type="submit"
|
|
32
|
+
<%= "disabled" unless available? %>
|
|
33
|
+
class="inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-4 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))] disabled:cursor-not-allowed disabled:opacity-50">
|
|
34
|
+
<%= button_label %>
|
|
35
|
+
</button>
|
|
36
|
+
<% end %>
|
|
37
|
+
</div>
|
|
38
|
+
<% end %>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Senren
|
|
4
|
+
# A product tile with an add-to-cart action.
|
|
5
|
+
#
|
|
6
|
+
# Adding to a cart is an ordinary form submission, so this component ships no
|
|
7
|
+
# Stimulus controller. That is deliberate: a listing page renders this tile
|
|
8
|
+
# many times, and a controller here would download JavaScript on a page that
|
|
9
|
+
# does not need any.
|
|
10
|
+
#
|
|
11
|
+
# Price is passed in already formatted. Currency formatting is locale- and
|
|
12
|
+
# money-library-specific and belongs to the host app; a UI library that
|
|
13
|
+
# guesses gets it wrong in another country.
|
|
14
|
+
class ProductCardComponent < BaseComponent
|
|
15
|
+
VARIANTS = {
|
|
16
|
+
default: 'border-[hsl(var(--senren-border))]',
|
|
17
|
+
featured: 'border-[hsl(var(--senren-primary))] ring-1 ring-[hsl(var(--senren-primary)/0.35)]'
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
SIZES = { md: '' }.freeze
|
|
21
|
+
|
|
22
|
+
def initialize(title:, price:, url:, image_url: nil, description: nil, badge: nil,
|
|
23
|
+
action_label: 'Add to cart', available: true, method: :post,
|
|
24
|
+
variant: :default, id: nil, class_name: nil, **html)
|
|
25
|
+
super(variant: variant, size: :md, class_name: class_name, **html)
|
|
26
|
+
@title = title
|
|
27
|
+
@price = price
|
|
28
|
+
@url = url
|
|
29
|
+
@image_url = image_url
|
|
30
|
+
@description = description
|
|
31
|
+
@badge = badge
|
|
32
|
+
@action_label = action_label
|
|
33
|
+
@available = available
|
|
34
|
+
@method = method
|
|
35
|
+
@dom_id = id || senren_dom_id(title)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
attr_reader :title, :price, :image_url, :description, :badge, :action_label, :method, :dom_id
|
|
39
|
+
|
|
40
|
+
def available? = @available == true
|
|
41
|
+
|
|
42
|
+
# Untrusted values reach href/src attributes, so both go through the shared
|
|
43
|
+
# guards rather than being interpolated directly.
|
|
44
|
+
def safe_action_url = safe_url(@url)
|
|
45
|
+
def safe_image_url = safe_media_url(@image_url)
|
|
46
|
+
|
|
47
|
+
def button_label = available? ? action_label : 'Out of stock'
|
|
48
|
+
end
|
|
49
|
+
end
|